Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ precheck permissions or change Contexts. Server errors retain a nonzero exit
status; `--json` preserves the server error body on stdout, with diagnostics on
stderr.

The list also includes permission counts. Use repeated `--scope` options on
`realmroot toolbox platform context` to compare permission matches across the
complete list without changing the selected Context. Before an access
request, CLI prints the selected Context's available ID, name, type, and
selection source to stderr. JSON request results include that Context and the
requested scopes.
Permission matches are informational: the server decides whether to accept the
request, and its JSON error response is preserved on stdout with `--json`.

Use `realmroot toolbox sync <resource-server>` after that Resource Server
publishes a changed OpenAPI contract. Sync bypasses the cached OpenAPI document
and atomically refreshes the generated command catalog. It does not request
Expand Down
1 change: 1 addition & 0 deletions internal/catalog/command_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ var toolboxCommands = []CommandHelp{

var resourceServerCommands = []CommandHelp{
{Name: "context", Usage: "<resource-server> context", Description: "list available Contexts"},
{Name: "context", Usage: "<resource-server> context --scope <scope>...", Description: "compare permission matches across all Contexts"},
{Name: "context", Usage: "<resource-server> context show <context-id>", Description: "show one Context"},
{Name: "context", Usage: "<resource-server> context [use <context-id>|clear]", Description: "select or clear the default Context"},
}
Expand Down
13 changes: 6 additions & 7 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func (a *App) execCommand() *cobra.Command {
return err
}
observability.LogDuration(logger, observability.LevelTrace, "authorization_context.discover", phaseStartedAt, "resource_server", server.CommandName)
selected, err := a.resolveContext(service, server, details, options.context)
selected, source, err := a.resolveContextSelection(service, server, details, options.context)
if err != nil {
return err
}
Expand All @@ -182,7 +182,7 @@ func (a *App) execCommand() *cobra.Command {
ExactAuthorizationContext: true,
EffectiveScopes: executionScopes(details, selected, server.Scopes),
RequestAuthority: func(ctx context.Context, scopes []string) error {
_, err := accessService.Request(ctx, server, scopes, selected, "Run the requested native command", access.RequestOptions{})
_, err := a.requestAccess(ctx, accessService, server, scopes, details, selected, source, "Run the requested native command", access.RequestOptions{})
return err
},
})
Expand Down Expand Up @@ -303,19 +303,18 @@ func (a *App) requestCommand() *cobra.Command {
if err != nil {
return err
}
details, err := a.resolveContext(agentService, server, contexts, contextID)
details, source, err := a.resolveContextSelection(agentService, server, contexts, contextID)
if err != nil {
return err
}
accessService, err := access.New(agentService, httpClient)
if err != nil {
return err
}
receipt, err := accessService.Request(ctx, server, scopes, details, reason, access.RequestOptions{Handoff: handoff})
receipt, err := a.requestAccess(ctx, accessService, server, scopes, contexts, details, source, reason, access.RequestOptions{Handoff: handoff})
if err != nil {
var responseError *access.ResponseError
if a.json && errors.As(err, &responseError) && json.Valid(responseError.Body) {
if printErr := a.printJSON(json.RawMessage(responseError.Body)); printErr != nil {
if a.json && len(receipt.Error) > 0 {
if printErr := a.printJSON(receipt.Error); printErr != nil {
return printErr
}
}
Expand Down
175 changes: 175 additions & 0 deletions internal/cli/context_access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package cli

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"slices"
"strings"
"text/tabwriter"

"github.com/realmroot/cli/internal/access"
"github.com/realmroot/cli/internal/catalog"
)

type scopeMatch struct {
Status string `json:"status"`
Authorized []string `json:"authorizedScopes"`
Requestable []string `json:"requestableScopes"`
Unavailable []string `json:"unavailableScopes"`
}

type selectedContextSummary struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Source string `json:"source"`
}

type accessRequestResult struct {
access.Receipt
Context selectedContextSummary `json:"context"`
RequestedScopes []string `json:"requestedScopes"`
Contexts []contextListItem `json:"contexts"`
Error json.RawMessage `json:"-"`
}

type resourceAccessRequester interface {
Request(context.Context, catalog.ResourceServer, []string, []map[string]any, string, access.RequestOptions) (access.Receipt, error)
}

func requestedScopes(scopes []string) []string {
result := make([]string, 0, len(scopes))
for _, scope := range scopes {
if scope = strings.TrimSpace(scope); scope != "" {
result = append(result, scope)
}
}
slices.Sort(result)
return slices.Compact(result)
}

func contextIdentity(detail catalog.AuthorizationDetail) (string, string) {
if detail.AuthorizationDetail["type"] == "realmroot_authority" {
kind, _ := detail.AuthorizationDetail["authority"].(string)
return detail.ID, kind
}
return detail.ID, "resource"
}

func matchContextScopes(detail catalog.AuthorizationDetail, scopes []string) scopeMatch {
match := scopeMatch{Status: "authorized", Authorized: []string{}, Requestable: []string{}, Unavailable: []string{}}
for _, scope := range requestedScopes(scopes) {
switch {
case slices.Contains(detail.AuthorizedScopes, scope):
match.Authorized = append(match.Authorized, scope)
case slices.Contains(detail.RequestableScopes, scope):
match.Requestable = append(match.Requestable, scope)
default:
match.Unavailable = append(match.Unavailable, scope)
}
}
if len(match.Requestable) > 0 {
match.Status = "requestable"
}
if len(match.Unavailable) > 0 {
match.Status = "unavailable"
// An external account can be connected or expanded through approval.
if detail.AccountAuthorizationStatus != "not_required" {
match.Status = "account_authorization_required"
}
}
return match
}

func contextMatches(details []catalog.AuthorizationDetail, selected []map[string]any, scopes []string, published ...catalog.Scope) []contextListItem {
items := listContexts(details, selected, published...)
for index, detail := range details {
match := matchContextScopes(detail, scopes)
items[index].Match = &match
}
return items
}

func summarizeAccessRequest(server catalog.ResourceServer, scopes []string, details []catalog.AuthorizationDetail, selected []map[string]any, source string) accessRequestResult {
scopes = requestedScopes(scopes)
result := accessRequestResult{
Receipt: access.Receipt{ResourceServer: server.CommandName},
Context: selectedContextSummary{Source: source},
RequestedScopes: scopes,
Contexts: contextMatches(details, selected, scopes, server.Scopes...),
}
for _, detail := range details {
if !sameDetails(detail.AuthorizationDetail, selected) {
continue
}
id, kind := contextIdentity(detail)
result.Context = selectedContextSummary{ID: id, Name: detail.Name, Type: kind, Source: source}
}
return result
}

func (a *App) requestAccess(ctx context.Context, service resourceAccessRequester, server catalog.ResourceServer, scopes []string, details []catalog.AuthorizationDetail, selected []map[string]any, source, reason string, options access.RequestOptions) (accessRequestResult, error) {
result := summarizeAccessRequest(server, scopes, details, selected, source)
if printErr := printAccessContext(a.stderr, result); printErr != nil {
return result, printErr
}
var err error
result.Receipt, err = service.Request(ctx, server, result.RequestedScopes, selected, reason, options)
if err != nil {
var responseError *access.ResponseError
if errors.As(err, &responseError) && json.Valid(responseError.Body) {
result.Error = append(json.RawMessage(nil), responseError.Body...)
}
}
return result, err
}

func printAccessContext(w io.Writer, result accessRequestResult) error {
if _, err := fmt.Fprintf(w, "Resource Server: %s\nSelection source: %s\nRequested scopes: %s\n",
result.ResourceServer, result.Context.Source, strings.Join(result.RequestedScopes, ", ")); err != nil {
return err
}
if result.Context.Name != "" {
if _, err := fmt.Fprintf(w, "Context: %s (%s)\n", result.Context.Name, result.Context.Type); err != nil {
return err
}
}
if result.Context.ID != "" {
if _, err := fmt.Fprintf(w, "Context ID: %s\n", result.Context.ID); err != nil {
return err
}
}
if len(result.Contexts) == 0 {
return nil
}
return printContextRows(w, result.Contexts)
}

func printContextRows(w io.Writer, items []contextListItem) error {
table := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(table, "CURRENT\tID\tNAME\tTYPE\tACCOUNT\tAGENT GRANTED\tREQUESTABLE\tNOT CURRENTLY REQUESTABLE\tGRANTED COUNT\tREQUESTABLE COUNT\tMATCH")
for _, item := range items {
current, match := "", ""
if item.Current {
current = "*"
}
id := item.ID
if id == "" {
id = "-"
}
if item.Match != nil {
match = item.Match.Status
if len(item.Match.Unavailable) > 0 {
match += ": " + strings.Join(item.Match.Unavailable, ", ")
}
}
fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%d\t%s\n",
current, id, item.Name, item.Type, item.AccountAuthorizationStatus,
scopeNames(item.AuthorizedScopes), scopeNames(item.RequestableScopes), scopeNames(item.UnavailableScopes),
item.AuthorizedScopeCount, item.RequestableScopeCount, match)
}
return table.Flush()
}
157 changes: 157 additions & 0 deletions internal/cli/context_access_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package cli

import (
"bytes"
"context"
"encoding/json"
"net/http"
"reflect"
"testing"

"github.com/realmroot/cli/internal/access"
"github.com/realmroot/cli/internal/agent"
"github.com/realmroot/cli/internal/catalog"
)

func authorityContexts() []catalog.AuthorizationDetail {
return []catalog.AuthorizationDetail{
{ID: "ctx_user", Name: "Ambor", AccountAuthorizationStatus: "not_required", AuthorizationDetail: map[string]any{"type": "realmroot_authority", "authority": "user", "id": "user-1"}, AuthorizedScopes: []string{"agents:read"}},
{ID: "ctx_org", Name: "Platform", AccountAuthorizationStatus: "not_required", AuthorizationDetail: map[string]any{"type": "realmroot_authority", "authority": "organization", "id": "org-1"}, AuthorizedScopes: []string{"applications:read"}, RequestableScopes: []string{"permissions:read"}},
}
}

type accessRecorder struct {
calls int
details []map[string]any
scopes []string
status string
err error
}

func (r *accessRecorder) Request(_ context.Context, server catalog.ResourceServer, scopes []string, details []map[string]any, _ string, _ access.RequestOptions) (access.Receipt, error) {
r.calls++
r.details, r.scopes = details, scopes
return access.Receipt{Status: r.status, ResourceServer: server.CommandName, Scopes: scopes}, r.err
}

func TestAccessRequestDefersBoundaryDecisionToServer(t *testing.T) {
// [spec: cli/access-context-preflight]
details := authorityContexts()
selected := []map[string]any{details[0].AuthorizationDetail}
body := []byte(`{"error":{"code":"requested_scopes_exceed_controller_boundary","message":"Controller cannot grant these scopes. No approval request was created.","requestId":"server-request-1","details":{"context":{"id":"user-1","type":"user"},"scopes":["applications:read"]}}}`)
service := &accessRecorder{err: &access.ResponseError{StatusCode: 403, Body: body}}
var stderr bytes.Buffer
result, err := (&App{stderr: &stderr}).requestAccess(context.Background(), service, catalog.ResourceServer{CommandName: "platform", ConnectionStatus: "not_required"}, []string{"applications:read"}, details, selected, "saved_default", "inspect", access.RequestOptions{})
if err != service.err || service.calls != 1 || !reflect.DeepEqual(service.details, selected) {
t.Fatalf("must call server without switching Context: err=%v service=%+v", err, service)
}
if !bytes.Equal(result.Error, body) {
t.Fatalf("server error changed: %s", result.Error)
}
if len(result.Contexts) != 2 || !result.Contexts[0].Current {
t.Fatalf("Contexts changed: %+v", result.Contexts)
}
// Discovery can be stale: a missing catalog permission must not override server success.
service.err, service.status = nil, "ready"
result, err = (&App{stderr: &stderr}).requestAccess(context.Background(), service, catalog.ResourceServer{ConnectionStatus: "not_required"}, []string{"applications:read"}, details, selected, "saved_default", "inspect", access.RequestOptions{})
if err != nil || result.Status != "ready" || len(result.Error) != 0 || service.calls != 2 {
t.Fatalf("server decision ignored: %+v %v", result, err)
}
}

func TestContextScopeComparisonKeepsEveryContextAndItsPermissionFacts(t *testing.T) {
// [spec: cli/resource-server-context]
details := authorityContexts()
selected := []map[string]any{details[0].AuthorizationDetail}
items := contextMatches(details, selected, []string{"permissions:read"},
catalog.Scope{Value: "agents:read"}, catalog.Scope{Value: "permissions:read"})
if len(items) != 2 || !items[0].Current || items[1].Current {
t.Fatalf("comparison filtered or changed Contexts: %+v", items)
}
if items[0].ID != "ctx_user" || items[0].Match.Status != "unavailable" || items[0].AuthorizedScopeCount != 1 || len(items[0].AuthorizedScopes) != 1 {
t.Fatalf("user Context facts changed: %+v", items[0])
}
if items[1].ID != "ctx_org" || items[1].Match.Status != "requestable" || items[1].RequestableScopeCount != 1 || len(items[1].RequestableScopes) != 1 {
t.Fatalf("organization Context facts changed: %+v", items[1])
}
var output bytes.Buffer
if err := (&App{stdout: &output}).printContexts(contextResult{
ResourceServer: "platform", Contexts: items, RequestedScopes: []string{"permissions:read"},
}); err != nil {
t.Fatal(err)
}
for _, expected := range []string{"ctx_user", "ctx_org", "agents:read", "permissions:read", "Requested scopes:"} {
if !bytes.Contains(output.Bytes(), []byte(expected)) {
t.Fatalf("comparison omitted %q: %s", expected, &output)
}
}
}

func TestContextScopeFlagCannotChangeSelection(t *testing.T) {
var stderr bytes.Buffer
err := (&App{stderr: &stderr}).contextCommand(t.Context(), nil, nil, "platform", []string{"--scope", "agents:read", "use", "ctx_user"})
if err == nil || err.Error() != "--scope applies only to the Context list" {
t.Fatalf("selection with scope error = %v", err)
}
}

func TestAccessResultsKeepContextMetadataAndExternalExpansion(t *testing.T) {
// [spec: cli/access-context-preflight]
for _, external := range []bool{false, true} {
for _, status := range []string{"pending", "ready"} {
details := authorityContexts()[1:]
server := catalog.ResourceServer{CommandName: "platform", ConnectionStatus: "not_required"}
if external {
server.ConnectionStatus = "connected"
details[0].AccountAuthorizationStatus = "authorized"
details[0].AuthorizedScopes, details[0].RequestableScopes = nil, nil
}
selected := []map[string]any{details[0].AuthorizationDetail}
service := &accessRecorder{status: status}
var stderr bytes.Buffer
result, err := (&App{stderr: &stderr, json: true}).requestAccess(context.Background(), service, server, []string{" permissions:read ", "permissions:read"}, details, selected, "command_line", "inspect", access.RequestOptions{Handoff: true})
if err != nil || service.calls != 1 || result.Status != status || !reflect.DeepEqual(service.details, selected) || !reflect.DeepEqual(service.scopes, []string{"permissions:read"}) {
t.Fatalf("external=%v result=%+v err=%v service=%+v", external, result, err, service)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"id":"ctx_org"`, `"type":"organization"`, `"source":"command_line"`} {
if !bytes.Contains(encoded, []byte(want)) {
t.Fatalf("missing %s in %s", want, encoded)
}
}
if stderr.Len() == 0 {
t.Fatal("missing pre-approval diagnostics in JSON mode")
}
}
}
}

func TestContextSelectionReportsSourceWithoutChangingDefault(t *testing.T) {
// [spec: cli/access-context-preflight]
t.Setenv("REALMROOT_STATE_DIR", t.TempDir())
service, err := agent.NewService("https://id.example.com", http.DefaultClient)
if err != nil {
t.Fatal(err)
}
server := catalog.ResourceServer{ResourceURL: "https://api.example.com"}
details := authorityContexts()
app := &App{}
_, source, err := app.resolveContextSelection(service, server, details[:1], "")
if err != nil || source != "only_available" {
t.Fatalf("source=%s err=%v", source, err)
}
if err := service.StoreContext(server.ResourceURL, []map[string]any{details[0].AuthorizationDetail}); err != nil {
t.Fatal(err)
}
selected, source, err := app.resolveContextSelection(service, server, details, "ctx_org")
if err != nil || source != "command_line" || !sameDetails(details[1].AuthorizationDetail, selected) {
t.Fatalf("source=%s selected=%v err=%v", source, selected, err)
}
selected, source, err = app.resolveContextSelection(service, server, details, "")
if err != nil || source != "saved_default" || !sameDetails(details[0].AuthorizationDetail, selected) {
t.Fatalf("source=%s selected=%v err=%v", source, selected, err)
}
}
Loading
Loading