From ffcd1a7a6189baf898fe7ba17e683fff79c1d84b Mon Sep 17 00:00:00 2001 From: jarvis Date: Sat, 5 Sep 2026 14:02:25 -0400 Subject: [PATCH 1/2] fix(access): preserve server errors and explain selected contexts --- README.md | 15 +++ internal/access/access.go | 13 ++- internal/access/access_test.go | 35 ++++++ internal/catalog/command_surface.go | 1 + internal/cli/cli.go | 13 ++- internal/cli/context_access.go | 170 ++++++++++++++++++++++++++++ internal/cli/context_access_test.go | 121 ++++++++++++++++++++ internal/cli/contexts.go | 89 +++++++++------ specs/cli.feature | 15 ++- 9 files changed, 430 insertions(+), 42 deletions(-) create mode 100644 internal/cli/context_access.go create mode 100644 internal/cli/context_access_test.go diff --git a/README.md b/README.md index 76f5d71..aa27384 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,21 @@ prints its service-defined description and safe attributes; `context use` selects the default. Context selection is independent of permission requests and credential storage. +The list includes each Context's type and authorized/requestable scope counts. +Use `realmroot toolbox platform context --scope applications:read --scope permissions:read` +to compare permission matches across the complete list. This does not filter or +select Contexts. Before requesting approval, CLI prints the selected Context and +whether it came from `--context`, a saved default, or the sole available choice. +The server checks native scopes against that Context's controller boundary before +creating an approval request. CLI permission matches are informational only. Select the intended Context explicitly and retry. JSON request +results include this selection metadata; preflight diagnostics go to stderr. +Server failures exit with code 1. With `--json`, stdout preserves the server's JSON +error response, including `error.code`, `message`, `requestId`, and `details`. +The server returns `requested_scopes_exceed_controller_boundary` for scopes the +controller cannot grant, with the Context and offending scopes in `details`. +CLI does not infer this error from discovery or synthesize server error codes. +Diagnostics remain on stderr. + Use `realmroot toolbox sync ` 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 diff --git a/internal/access/access.go b/internal/access/access.go index 918bf0f..d45631c 100644 --- a/internal/access/access.go +++ b/internal/access/access.go @@ -227,6 +227,17 @@ func receipt(server catalog.ResourceServer, resource string, scopes []string) Re return Receipt{Status: "ready", ResourceServer: server.CommandName, ResourceIndicator: resource, Scopes: scopes} } +// ResponseError preserves the server response without inventing a client error code. +type ResponseError struct { + Operation string + StatusCode int + Body []byte +} + +func (e *ResponseError) Error() string { + return fmt.Sprintf("%s: HTTP %d: %s", e.Operation, e.StatusCode, strings.TrimSpace(string(e.Body))) +} + func apiError(operation string, status int, body []byte) error { - return fmt.Errorf("%s: HTTP %d: %s", operation, status, strings.TrimSpace(string(body))) + return &ResponseError{Operation: operation, StatusCode: status, Body: append([]byte(nil), body...)} } diff --git a/internal/access/access_test.go b/internal/access/access_test.go index ff1e01c..ca466a2 100644 --- a/internal/access/access_test.go +++ b/internal/access/access_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net/http" + "net/http/httptest" "strings" "testing" @@ -16,6 +17,40 @@ var ( errAccessRequested = errors.New("access requested") ) +// Keep the real generated HTTP client; only bypass identity signing in this transport test. +type unsignedHTTPClient struct { + *realmrootapi.ClientWithResponses +} + +func (c unsignedHTTPClient) CreateAgentAuthorizationRequestWithResponse(ctx context.Context, body realmrootapi.CreateAgentAuthorizationRequestJSONRequestBody, _ ...realmrootapi.RequestEditorFn) (*realmrootapi.CreateAgentAuthorizationRequestResponse, error) { + return c.ClientWithResponses.CreateAgentAuthorizationRequestWithResponse(ctx, body) +} + +func TestRequestPreservesHTTPServerError(t *testing.T) { + calls := 0 + body := `{"error":{"code":"requested_scopes_exceed_controller_boundary","message":"Controller cannot grant these scopes. No approval request was created.","requestId":"request-1","details":{"context":{"id":"user-1","type":"user"},"scopes":["applications:read"]}}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != "POST" || !strings.HasSuffix(r.URL.Path, "/agent/access-requests") { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + client, err := realmrootapi.NewClientWithResponses(server.URL) + if err != nil { + t.Fatal(err) + } + service := &Service{api: unsignedHTTPClient{client}} + _, err = service.Request(context.Background(), catalog.ResourceServer{ID: "resource-1"}, []string{"applications:read"}, []map[string]any{{"type": "realmroot_authority", "authority": "user", "id": "user-1"}}, "inspect", RequestOptions{}) + var responseError *ResponseError + if !errors.As(err, &responseError) || responseError.StatusCode != 403 || string(responseError.Body) != body || calls != 1 { + t.Fatalf("HTTP error not preserved: %v (calls=%d)", err, calls) + } +} + type recordingClient struct { accessRequests int } diff --git a/internal/catalog/command_surface.go b/internal/catalog/command_surface.go index 4dc7091..ef3b3b1 100644 --- a/internal/catalog/command_surface.go +++ b/internal/catalog/command_surface.go @@ -13,6 +13,7 @@ var toolboxCommands = []CommandHelp{ var resourceServerCommands = []CommandHelp{ {Name: "context", Usage: " context", Description: "list available Contexts"}, + {Name: "context", Usage: " context --scope ...", Description: "compare permission matches across all Contexts"}, {Name: "context", Usage: " context show ", Description: "show one Context"}, {Name: "context", Usage: " context [use |clear]", Description: "select or clear the default Context"}, } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index aa7322c..e478271 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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 } @@ -179,7 +179,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 }, }) @@ -300,7 +300,7 @@ 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 } @@ -308,8 +308,13 @@ func (a *App) requestCommand() *cobra.Command { 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 { + if a.json && len(receipt.Error) > 0 { + if printErr := a.printJSON(receipt.Error); printErr != nil { + return printErr + } + } return err } return a.printJSON(receipt) diff --git a/internal/cli/context_access.go b/internal/cli/context_access.go new file mode 100644 index 0000000..a96b7ff --- /dev/null +++ b/internal/cli/context_access.go @@ -0,0 +1,170 @@ +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" { + id := detail.ID + kind, _ := detail.AuthorizationDetail["authority"].(string) + return 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) []contextListItem { + items := listContexts(details, selected) + 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), + } + 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\tAUTHORIZED\tREQUESTABLE\tMATCH") + for _, item := range items { + current, match := "", "" + if item.Current { + current = "*" + } + 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%d\t%d\t%s\n", current, item.ID, item.Name, item.Type, item.AccountAuthorizationStatus, item.AuthorizedScopeCount, item.RequestableScopeCount, match) + } + return table.Flush() +} diff --git a/internal/cli/context_access_test.go b/internal/cli/context_access_test.go new file mode 100644 index 0000000..fe9a540 --- /dev/null +++ b/internal/cli/context_access_test.go @@ -0,0 +1,121 @@ +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: "user-1", Name: "Ambor", AccountAuthorizationStatus: "not_required", AuthorizationDetail: map[string]any{"type": "realmroot_authority", "authority": "user", "id": "user-1"}, AuthorizedScopes: []string{"agents:read"}}, + {ID: "org-1", 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 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":"org-1"`, `"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, "org-1") + 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) + } +} diff --git a/internal/cli/contexts.go b/internal/cli/contexts.go index c4c7dd7..63b1d85 100644 --- a/internal/cli/contexts.go +++ b/internal/cli/contexts.go @@ -8,10 +8,10 @@ import ( "os" "sort" "strings" - "text/tabwriter" "github.com/realmroot/cli/internal/agent" "github.com/realmroot/cli/internal/catalog" + "github.com/spf13/pflag" ) type contextSummary struct { @@ -26,15 +26,20 @@ type contextSummary struct { } type contextListItem struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - AccountAuthorizationStatus string `json:"accountAuthorizationStatus"` - Current bool `json:"current"` + ID string `json:"id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + AccountAuthorizationStatus string `json:"accountAuthorizationStatus"` + Current bool `json:"current"` + AuthorizedScopeCount int `json:"authorizedScopeCount"` + RequestableScopeCount int `json:"requestableScopeCount"` + Match *scopeMatch `json:"match,omitempty"` } type contextResult struct { - ResourceServer string `json:"resourceServer"` - Contexts []contextListItem `json:"contexts"` + ResourceServer string `json:"resourceServer"` + Contexts []contextListItem `json:"contexts"` + RequestedScopes []string `json:"requestedScopes,omitempty"` } type contextSelectionResult struct { @@ -53,8 +58,10 @@ func (e contextUnavailableError) Error() string { func listContexts(details []catalog.AuthorizationDetail, selected []map[string]any) []contextListItem { result := make([]contextListItem, 0, len(details)) for _, detail := range details { + id, kind := contextIdentity(detail) result = append(result, contextListItem{ - ID: detail.ID, Name: detail.Name, AccountAuthorizationStatus: detail.AccountAuthorizationStatus, + ID: id, Type: kind, AuthorizedScopeCount: len(detail.AuthorizedScopes), RequestableScopeCount: len(detail.RequestableScopes), + Name: detail.Name, AccountAuthorizationStatus: detail.AccountAuthorizationStatus, Current: sameDetails(detail.AuthorizationDetail, selected), }) } @@ -76,6 +83,17 @@ func summarizeContexts(details []catalog.AuthorizationDetail, selected []map[str } func (a *App) contextCommand(ctx context.Context, service *agent.Service, client *catalog.Client, serverName string, args []string) error { + flags := pflag.NewFlagSet("context", pflag.ContinueOnError) + flags.SetOutput(a.stderr) + scopes := flags.StringArray("scope", nil, "show permission matches without filtering Contexts (repeatable)") + if err := flags.Parse(args); err != nil { + return err + } + args = flags.Args() + *scopes = requestedScopes(*scopes) + if len(*scopes) > 0 && len(args) > 0 { + return fmt.Errorf("--scope applies only to the Context list") + } server, err := client.Find(ctx, serverName) if err != nil { return err @@ -100,7 +118,11 @@ func (a *App) contextCommand(ctx context.Context, service *agent.Service, client return selectedErr } if len(args) == 0 { - return a.printContexts(contextResult{ResourceServer: server.CommandName, Contexts: listContexts(details, selected)}) + items := listContexts(details, selected) + if len(*scopes) > 0 { + items = contextMatches(details, selected, *scopes) + } + return a.printContexts(contextResult{ResourceServer: server.CommandName, Contexts: items, RequestedScopes: *scopes}) } if len(args) != 2 || (args[0] != "show" && args[0] != "use") { return fmt.Errorf("usage: realmroot toolbox %s context [show|use] | clear", server.CommandName) @@ -127,43 +149,48 @@ func (a *App) contextCommand(ctx context.Context, service *agent.Service, client return a.printContext(server.CommandName, summary) } -func (a *App) resolveContext(service *agent.Service, server catalog.ResourceServer, details []catalog.AuthorizationDetail, contextID string) ([]map[string]any, error) { - if contextID != "" { - detail, err := contextBySelector(details, contextID) +func (a *App) resolveContext(service *agent.Service, server catalog.ResourceServer, details []catalog.AuthorizationDetail, name string) ([]map[string]any, error) { + selected, _, err := a.resolveContextSelection(service, server, details, name) + return selected, err +} + +func (a *App) resolveContextSelection(service *agent.Service, server catalog.ResourceServer, details []catalog.AuthorizationDetail, name string) ([]map[string]any, string, error) { + if name != "" { + detail, err := contextBySelector(details, name) if err != nil { var unavailable contextUnavailableError if errors.As(err, &unavailable) { - return nil, fmt.Errorf("%w; connect or update it in Realmroot Connections: %s/connections", err, service.Origin()) + return nil, "", fmt.Errorf("%w; connect or update it in Realmroot Connections: %s/connections", err, service.Origin()) } - return nil, err + return nil, "", err } - return []map[string]any{detail.AuthorizationDetail}, nil + return []map[string]any{detail.AuthorizationDetail}, "command_line", nil } selected, err := service.SelectedContext(server.ResourceURL) if err == nil { for _, detail := range details { if sameDetails(detail.AuthorizationDetail, selected) { - return selected, nil + return selected, "saved_default", nil } } if len(details) == 0 { if err := service.ClearContext(server.ResourceURL); err != nil { - return nil, err + return nil, "", err } - return disconnectedAuthorizationDetails(server), nil + return disconnectedAuthorizationDetails(server), "resource_default", nil } - return nil, fmt.Errorf("the selected %s Context is no longer available; run `realmroot toolbox %s context`", server.CommandName, server.CommandName) + return nil, "", fmt.Errorf("the selected %s Context is no longer available; run `realmroot toolbox %s context`", server.CommandName, server.CommandName) } if !errors.Is(err, os.ErrNotExist) { - return nil, err + return nil, "", err } switch len(details) { case 0: - return disconnectedAuthorizationDetails(server), nil + return disconnectedAuthorizationDetails(server), "resource_default", nil case 1: - return []map[string]any{details[0].AuthorizationDetail}, nil + return []map[string]any{details[0].AuthorizationDetail}, "only_available", nil default: - return nil, fmt.Errorf("Resource Server %q has multiple Contexts; select one with `realmroot toolbox %s context use ` or pass --context ", server.CommandName, server.CommandName) + return nil, "", fmt.Errorf("Resource Server %q has multiple Contexts; select one with `realmroot toolbox %s context use ` or pass --context ", server.CommandName, server.CommandName) } } @@ -212,20 +239,10 @@ func (a *App) printContexts(result contextResult) error { fmt.Fprintf(a.stdout, "Resource Server %q does not define Contexts.\n", result.ResourceServer) return nil } - w := tabwriter.NewWriter(a.stdout, 0, 4, 2, ' ', 0) - fmt.Fprintln(w, "CURRENT\tID\tNAME\tACCOUNT") - for _, item := range result.Contexts { - current := "" - if item.Current { - current = "*" - } - id := item.ID - if id == "" { - id = "-" - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", current, id, item.Name, item.AccountAuthorizationStatus) + if len(result.RequestedScopes) > 0 { + fmt.Fprintf(a.stdout, "Requested scopes: %s\n", strings.Join(result.RequestedScopes, ", ")) } - return w.Flush() + return printContextRows(a.stdout, result.Contexts) } func (a *App) printContext(resourceServer string, item contextSummary) error { diff --git a/specs/cli.feature b/specs/cli.feature index 140e451..5dcb62c 100644 --- a/specs/cli.feature +++ b/specs/cli.feature @@ -80,7 +80,8 @@ Feature: Realmroot Toolbox command line Scenario: Inspect and select one Resource Server Context Given the Resource Server exposes one or more Contexts with service-defined names and attributes When the Agent runs "realmroot toolbox github context" - Then Toolbox lists each stable Context ID, display name, authorization status, and current selection + Then Toolbox lists every Context with its stable ID, name, type, authorization status, permission counts, and current selection + And repeated "--scope" options on the Context list show matching authorized, requestable, and unavailable scopes without filtering Contexts And Context details show the Resource Server supplied description and attributes When the Agent selects the Context by its stable ID Then subsequent GitHub operations use that Context by default @@ -106,6 +107,18 @@ Feature: Realmroot Toolbox command line And the resulting credential offer is stored without a target private key or access token And the command returns only the ready authority without exposing the internal credential binding + @journey:access-context-preflight @entrypoint:agent-request + Scenario: Inspect the selected Context before requesting authority + Given the Resource Server publishes Contexts and their authorized and requestable scopes + When the Agent requests scopes using an explicit, saved, or sole available Context + Then Toolbox reports the Context ID, name, type, and selection source before requesting approval + And it shows every Context's match for the requested scopes without changing the selected Context + And the CLI submits the selected Context and scopes to the server even when the discovery catalog reports missing permissions + And the server rejects scopes outside the controller boundary before creating an approval request + And a controller boundary failure exits with code 1 and preserves the server JSON error body including its code, message, request ID, and details + And pending and completed JSON results preserve the selected Context and selection source + But external account connection or scope expansion can still proceed through controller approval + @journey:task-scoped-access-handoff @entrypoint:agent-request Scenario: Hand an approval link to a remote controller Given the controller is not using the Agent's computer From 59646e29d6e3860b6719bae14263dbbf82ca3f66 Mon Sep 17 00:00:00 2001 From: jarvis Date: Sat, 5 Sep 2026 14:15:00 -0400 Subject: [PATCH 2/2] refactor(context): show permissions in the ordinary list --- README.md | 23 ++- internal/access/access_test.go | 6 +- internal/catalog/command_surface.go | 1 - internal/cli/cli.go | 13 +- internal/cli/context_access.go | 170 ----------------------- internal/cli/context_access_test.go | 121 ---------------- internal/cli/context_permissions_test.go | 43 ++++++ internal/cli/contexts.go | 114 ++++++++------- internal/cli/discovery.go | 4 +- specs/cli.feature | 23 ++- 10 files changed, 132 insertions(+), 386 deletions(-) delete mode 100644 internal/cli/context_access.go delete mode 100644 internal/cli/context_access_test.go create mode 100644 internal/cli/context_permissions_test.go diff --git a/README.md b/README.md index aa27384..89e7b17 100644 --- a/README.md +++ b/README.md @@ -135,21 +135,14 @@ installations, or other Contexts defined by that Resource Server. `context show` prints its service-defined description and safe attributes; `context use` selects the default. Context selection is independent of permission requests and credential storage. - -The list includes each Context's type and authorized/requestable scope counts. -Use `realmroot toolbox platform context --scope applications:read --scope permissions:read` -to compare permission matches across the complete list. This does not filter or -select Contexts. Before requesting approval, CLI prints the selected Context and -whether it came from `--context`, a saved default, or the sole available choice. -The server checks native scopes against that Context's controller boundary before -creating an approval request. CLI permission matches are informational only. Select the intended Context explicitly and retry. JSON request -results include this selection metadata; preflight diagnostics go to stderr. -Server failures exit with code 1. With `--json`, stdout preserves the server's JSON -error response, including `error.code`, `message`, `requestId`, and `details`. -The server returns `requested_scopes_exceed_controller_boundary` for scopes the -controller cannot grant, with the Context and offending scopes in `details`. -CLI does not infer this error from discovery or synthesize server error codes. -Diagnostics remain on stderr. +The ordinary Context list includes Agent-granted scopes, requestable scopes, and +published scopes not currently requestable. These describe current permissions; +external accounts may require connection or expanded authorization. + +Access requests always go to the server for authorization checks. CLI does not +precheck permissions or change Contexts. Server errors retain a nonzero exit +status; `--json` preserves the server error body on stdout, with diagnostics on +stderr. Use `realmroot toolbox sync ` after that Resource Server publishes a changed OpenAPI contract. Sync bypasses the cached OpenAPI document diff --git a/internal/access/access_test.go b/internal/access/access_test.go index ca466a2..03da430 100644 --- a/internal/access/access_test.go +++ b/internal/access/access_test.go @@ -28,14 +28,14 @@ func (c unsignedHTTPClient) CreateAgentAuthorizationRequestWithResponse(ctx cont func TestRequestPreservesHTTPServerError(t *testing.T) { calls := 0 - body := `{"error":{"code":"requested_scopes_exceed_controller_boundary","message":"Controller cannot grant these scopes. No approval request was created.","requestId":"request-1","details":{"context":{"id":"user-1","type":"user"},"scopes":["applications:read"]}}}` + body := `{"error":{"code":"bad_request","message":"Controller cannot grant these scopes. No approval request was created.","requestId":"request-1","details":{"context":{"id":"user-1","type":"user"},"scopes":["applications:read"]}}}` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ if r.Method != "POST" || !strings.HasSuffix(r.URL.Path, "/agent/access-requests") { t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) + w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(body)) })) defer server.Close() @@ -46,7 +46,7 @@ func TestRequestPreservesHTTPServerError(t *testing.T) { service := &Service{api: unsignedHTTPClient{client}} _, err = service.Request(context.Background(), catalog.ResourceServer{ID: "resource-1"}, []string{"applications:read"}, []map[string]any{{"type": "realmroot_authority", "authority": "user", "id": "user-1"}}, "inspect", RequestOptions{}) var responseError *ResponseError - if !errors.As(err, &responseError) || responseError.StatusCode != 403 || string(responseError.Body) != body || calls != 1 { + if !errors.As(err, &responseError) || responseError.StatusCode != 400 || string(responseError.Body) != body || calls != 1 { t.Fatalf("HTTP error not preserved: %v (calls=%d)", err, calls) } } diff --git a/internal/catalog/command_surface.go b/internal/catalog/command_surface.go index ef3b3b1..4dc7091 100644 --- a/internal/catalog/command_surface.go +++ b/internal/catalog/command_surface.go @@ -13,7 +13,6 @@ var toolboxCommands = []CommandHelp{ var resourceServerCommands = []CommandHelp{ {Name: "context", Usage: " context", Description: "list available Contexts"}, - {Name: "context", Usage: " context --scope ...", Description: "compare permission matches across all Contexts"}, {Name: "context", Usage: " context show ", Description: "show one Context"}, {Name: "context", Usage: " context [use |clear]", Description: "select or clear the default Context"}, } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e478271..8e5a3cf 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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, source, err := a.resolveContextSelection(service, server, details, options.context) + selected, err := a.resolveContext(service, server, details, options.context) if err != nil { return err } @@ -179,7 +179,7 @@ func (a *App) execCommand() *cobra.Command { ExactAuthorizationContext: true, EffectiveScopes: executionScopes(details, selected, server.Scopes), RequestAuthority: func(ctx context.Context, scopes []string) error { - _, err := a.requestAccess(ctx, accessService, server, scopes, details, selected, source, "Run the requested native command", access.RequestOptions{}) + _, err := accessService.Request(ctx, server, scopes, selected, "Run the requested native command", access.RequestOptions{}) return err }, }) @@ -300,7 +300,7 @@ func (a *App) requestCommand() *cobra.Command { if err != nil { return err } - details, source, err := a.resolveContextSelection(agentService, server, contexts, contextID) + details, err := a.resolveContext(agentService, server, contexts, contextID) if err != nil { return err } @@ -308,10 +308,11 @@ func (a *App) requestCommand() *cobra.Command { if err != nil { return err } - receipt, err := a.requestAccess(ctx, accessService, server, scopes, contexts, details, source, reason, access.RequestOptions{Handoff: handoff}) + receipt, err := accessService.Request(ctx, server, scopes, details, reason, access.RequestOptions{Handoff: handoff}) if err != nil { - if a.json && len(receipt.Error) > 0 { - if printErr := a.printJSON(receipt.Error); printErr != 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 { return printErr } } diff --git a/internal/cli/context_access.go b/internal/cli/context_access.go deleted file mode 100644 index a96b7ff..0000000 --- a/internal/cli/context_access.go +++ /dev/null @@ -1,170 +0,0 @@ -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" { - id := detail.ID - kind, _ := detail.AuthorizationDetail["authority"].(string) - return 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) []contextListItem { - items := listContexts(details, selected) - 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), - } - 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\tAUTHORIZED\tREQUESTABLE\tMATCH") - for _, item := range items { - current, match := "", "" - if item.Current { - current = "*" - } - 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%d\t%d\t%s\n", current, item.ID, item.Name, item.Type, item.AccountAuthorizationStatus, item.AuthorizedScopeCount, item.RequestableScopeCount, match) - } - return table.Flush() -} diff --git a/internal/cli/context_access_test.go b/internal/cli/context_access_test.go deleted file mode 100644 index fe9a540..0000000 --- a/internal/cli/context_access_test.go +++ /dev/null @@ -1,121 +0,0 @@ -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: "user-1", Name: "Ambor", AccountAuthorizationStatus: "not_required", AuthorizationDetail: map[string]any{"type": "realmroot_authority", "authority": "user", "id": "user-1"}, AuthorizedScopes: []string{"agents:read"}}, - {ID: "org-1", 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 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":"org-1"`, `"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, "org-1") - 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) - } -} diff --git a/internal/cli/context_permissions_test.go b/internal/cli/context_permissions_test.go new file mode 100644 index 0000000..a0eab10 --- /dev/null +++ b/internal/cli/context_permissions_test.go @@ -0,0 +1,43 @@ +package cli + +import ( + "bytes" + "encoding/json" + "github.com/realmroot/cli/internal/catalog" + "reflect" + "strings" + "testing" +) + +func TestContextListShowsPermissionNamesWithoutExtraFlags(t *testing.T) { + // [spec: cli/resource-server-context] + details := []catalog.AuthorizationDetail{ + {ID: "user-1", Name: "Ambor", AuthorizationDetail: map[string]any{"type": "realmroot_authority", "authority": "user", "id": "user-1"}, AuthorizedScopes: []string{"agents:read"}, RequestableScopes: []string{"agents:write"}}, + {ID: "org-1", Name: "Platform", AuthorizationDetail: map[string]any{"type": "realmroot_authority", "authority": "organization", "id": "org-1"}, AuthorizedScopes: []string{"applications:read"}}, + } + items := listContexts(details, []map[string]any{details[0].AuthorizationDetail}, catalog.Scope{Value: "agents:read"}, catalog.Scope{Value: "agents:write"}, catalog.Scope{Value: "applications:read"}) + if len(items) != 2 || !items[0].Current || items[1].Current || !reflect.DeepEqual(items[0].UnavailableScopes, []string{"applications:read"}) || !reflect.DeepEqual(items[0].AuthorizedScopes, []string{"agents:read"}) || !reflect.DeepEqual(items[0].RequestableScopes, []string{"agents:write"}) { + t.Fatalf("incorrect Context list: %+v", items) + } + for _, asJSON := range []bool{false, true} { + var output bytes.Buffer + app := &App{stdout: &output, json: asJSON} + if err := app.printContexts(contextResult{ResourceServer: "platform", Contexts: items}); err != nil { + t.Fatal(err) + } + for _, expected := range []string{"Ambor", "Platform", "user-1", "org-1", "agents:read", "agents:write", "applications:read"} { + if !strings.Contains(output.String(), expected) { + t.Fatalf("list omitted %s: %s", expected, &output) + } + } + if asJSON { + var result contextResult + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(result.Contexts, items) { + t.Fatalf("JSON changed permission facts: %s", &output) + } + } + } +} diff --git a/internal/cli/contexts.go b/internal/cli/contexts.go index 63b1d85..66c92ca 100644 --- a/internal/cli/contexts.go +++ b/internal/cli/contexts.go @@ -6,12 +6,13 @@ import ( "errors" "fmt" "os" + "slices" "sort" "strings" + "text/tabwriter" "github.com/realmroot/cli/internal/agent" "github.com/realmroot/cli/internal/catalog" - "github.com/spf13/pflag" ) type contextSummary struct { @@ -26,20 +27,19 @@ type contextSummary struct { } type contextListItem struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Type string `json:"type"` - AccountAuthorizationStatus string `json:"accountAuthorizationStatus"` - Current bool `json:"current"` - AuthorizedScopeCount int `json:"authorizedScopeCount"` - RequestableScopeCount int `json:"requestableScopeCount"` - Match *scopeMatch `json:"match,omitempty"` + Type string `json:"type"` + AuthorizedScopes []string `json:"authorizedScopes"` + RequestableScopes []string `json:"requestableScopes"` + UnavailableScopes []string `json:"unavailableScopes"` + ID string `json:"id,omitempty"` + Name string `json:"name"` + AccountAuthorizationStatus string `json:"accountAuthorizationStatus"` + Current bool `json:"current"` } type contextResult struct { - ResourceServer string `json:"resourceServer"` - Contexts []contextListItem `json:"contexts"` - RequestedScopes []string `json:"requestedScopes,omitempty"` + ResourceServer string `json:"resourceServer"` + Contexts []contextListItem `json:"contexts"` } type contextSelectionResult struct { @@ -55,13 +55,22 @@ func (e contextUnavailableError) Error() string { return fmt.Sprintf("Context ID %q is not available", e.id) } -func listContexts(details []catalog.AuthorizationDetail, selected []map[string]any) []contextListItem { +func listContexts(details []catalog.AuthorizationDetail, selected []map[string]any, scopes ...catalog.Scope) []contextListItem { result := make([]contextListItem, 0, len(details)) for _, detail := range details { - id, kind := contextIdentity(detail) + kind := "resource" + if detail.AuthorizationDetail["type"] == "realmroot_authority" { + kind, _ = detail.AuthorizationDetail["authority"].(string) + } + unavailable := []string{} + for _, scope := range scopes { + if !slices.Contains(detail.AuthorizedScopes, scope.Value) && !slices.Contains(detail.RequestableScopes, scope.Value) { + unavailable = append(unavailable, scope.Value) + } + } result = append(result, contextListItem{ - ID: id, Type: kind, AuthorizedScopeCount: len(detail.AuthorizedScopes), RequestableScopeCount: len(detail.RequestableScopes), - Name: detail.Name, AccountAuthorizationStatus: detail.AccountAuthorizationStatus, + Type: kind, AuthorizedScopes: append([]string{}, detail.AuthorizedScopes...), RequestableScopes: append([]string{}, detail.RequestableScopes...), UnavailableScopes: unavailable, + ID: detail.ID, Name: detail.Name, AccountAuthorizationStatus: detail.AccountAuthorizationStatus, Current: sameDetails(detail.AuthorizationDetail, selected), }) } @@ -83,17 +92,6 @@ func summarizeContexts(details []catalog.AuthorizationDetail, selected []map[str } func (a *App) contextCommand(ctx context.Context, service *agent.Service, client *catalog.Client, serverName string, args []string) error { - flags := pflag.NewFlagSet("context", pflag.ContinueOnError) - flags.SetOutput(a.stderr) - scopes := flags.StringArray("scope", nil, "show permission matches without filtering Contexts (repeatable)") - if err := flags.Parse(args); err != nil { - return err - } - args = flags.Args() - *scopes = requestedScopes(*scopes) - if len(*scopes) > 0 && len(args) > 0 { - return fmt.Errorf("--scope applies only to the Context list") - } server, err := client.Find(ctx, serverName) if err != nil { return err @@ -118,11 +116,7 @@ func (a *App) contextCommand(ctx context.Context, service *agent.Service, client return selectedErr } if len(args) == 0 { - items := listContexts(details, selected) - if len(*scopes) > 0 { - items = contextMatches(details, selected, *scopes) - } - return a.printContexts(contextResult{ResourceServer: server.CommandName, Contexts: items, RequestedScopes: *scopes}) + return a.printContexts(contextResult{ResourceServer: server.CommandName, Contexts: listContexts(details, selected, server.Scopes...)}) } if len(args) != 2 || (args[0] != "show" && args[0] != "use") { return fmt.Errorf("usage: realmroot toolbox %s context [show|use] | clear", server.CommandName) @@ -149,48 +143,43 @@ func (a *App) contextCommand(ctx context.Context, service *agent.Service, client return a.printContext(server.CommandName, summary) } -func (a *App) resolveContext(service *agent.Service, server catalog.ResourceServer, details []catalog.AuthorizationDetail, name string) ([]map[string]any, error) { - selected, _, err := a.resolveContextSelection(service, server, details, name) - return selected, err -} - -func (a *App) resolveContextSelection(service *agent.Service, server catalog.ResourceServer, details []catalog.AuthorizationDetail, name string) ([]map[string]any, string, error) { - if name != "" { - detail, err := contextBySelector(details, name) +func (a *App) resolveContext(service *agent.Service, server catalog.ResourceServer, details []catalog.AuthorizationDetail, contextID string) ([]map[string]any, error) { + if contextID != "" { + detail, err := contextBySelector(details, contextID) if err != nil { var unavailable contextUnavailableError if errors.As(err, &unavailable) { - return nil, "", fmt.Errorf("%w; connect or update it in Realmroot Connections: %s/connections", err, service.Origin()) + return nil, fmt.Errorf("%w; connect or update it in Realmroot Connections: %s/connections", err, service.Origin()) } - return nil, "", err + return nil, err } - return []map[string]any{detail.AuthorizationDetail}, "command_line", nil + return []map[string]any{detail.AuthorizationDetail}, nil } selected, err := service.SelectedContext(server.ResourceURL) if err == nil { for _, detail := range details { if sameDetails(detail.AuthorizationDetail, selected) { - return selected, "saved_default", nil + return selected, nil } } if len(details) == 0 { if err := service.ClearContext(server.ResourceURL); err != nil { - return nil, "", err + return nil, err } - return disconnectedAuthorizationDetails(server), "resource_default", nil + return disconnectedAuthorizationDetails(server), nil } - return nil, "", fmt.Errorf("the selected %s Context is no longer available; run `realmroot toolbox %s context`", server.CommandName, server.CommandName) + return nil, fmt.Errorf("the selected %s Context is no longer available; run `realmroot toolbox %s context`", server.CommandName, server.CommandName) } if !errors.Is(err, os.ErrNotExist) { - return nil, "", err + return nil, err } switch len(details) { case 0: - return disconnectedAuthorizationDetails(server), "resource_default", nil + return disconnectedAuthorizationDetails(server), nil case 1: - return []map[string]any{details[0].AuthorizationDetail}, "only_available", nil + return []map[string]any{details[0].AuthorizationDetail}, nil default: - return nil, "", fmt.Errorf("Resource Server %q has multiple Contexts; select one with `realmroot toolbox %s context use ` or pass --context ", server.CommandName, server.CommandName) + return nil, fmt.Errorf("Resource Server %q has multiple Contexts; select one with `realmroot toolbox %s context use ` or pass --context ", server.CommandName, server.CommandName) } } @@ -239,10 +228,20 @@ func (a *App) printContexts(result contextResult) error { fmt.Fprintf(a.stdout, "Resource Server %q does not define Contexts.\n", result.ResourceServer) return nil } - if len(result.RequestedScopes) > 0 { - fmt.Fprintf(a.stdout, "Requested scopes: %s\n", strings.Join(result.RequestedScopes, ", ")) + w := tabwriter.NewWriter(a.stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "CURRENT\tID\tNAME\tTYPE\tACCOUNT\tAGENT GRANTED\tREQUESTABLE\tNOT CURRENTLY REQUESTABLE") + for _, item := range result.Contexts { + current := "" + if item.Current { + current = "*" + } + id := item.ID + if id == "" { + id = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", current, id, item.Name, item.Type, item.AccountAuthorizationStatus, scopeNames(item.AuthorizedScopes), scopeNames(item.RequestableScopes), scopeNames(item.UnavailableScopes)) } - return printContextRows(a.stdout, result.Contexts) + return w.Flush() } func (a *App) printContext(resourceServer string, item contextSummary) error { @@ -274,3 +273,10 @@ func (a *App) printContext(resourceServer string, item contextSummary) error { } return nil } + +func scopeNames(scopes []string) string { + if len(scopes) == 0 { + return "-" + } + return strings.Join(scopes, ", ") +} diff --git a/internal/cli/discovery.go b/internal/cli/discovery.go index b4c9454..3883d63 100644 --- a/internal/cli/discovery.go +++ b/internal/cli/discovery.go @@ -195,7 +195,7 @@ func buildResourceServerOverview(server catalog.ResourceServer, details []catalo } if !options.All && resourceServerInventoryIsLarge(server, details, operations) { overview.Mode = overviewModeCompact - overview.Contexts = listContexts(details, selected) + overview.Contexts = listContexts(details, selected, server.Scopes...) if len(overview.Contexts) > maxCompactAuthorization { overview.Contexts = overview.Contexts[:maxCompactAuthorization] overview.ContextTruncated = true @@ -204,7 +204,7 @@ func buildResourceServerOverview(server catalog.ResourceServer, details []catalo } overview.Mode = overviewModeExpanded overview.Scopes = append([]catalog.Scope(nil), server.Scopes...) - overview.Contexts = listContexts(details, selected) + overview.Contexts = listContexts(details, selected, server.Scopes...) overview.Operations = summarizeOperations(operations, "") return overview } diff --git a/specs/cli.feature b/specs/cli.feature index 5dcb62c..0393fea 100644 --- a/specs/cli.feature +++ b/specs/cli.feature @@ -80,8 +80,8 @@ Feature: Realmroot Toolbox command line Scenario: Inspect and select one Resource Server Context Given the Resource Server exposes one or more Contexts with service-defined names and attributes When the Agent runs "realmroot toolbox github context" - Then Toolbox lists every Context with its stable ID, name, type, authorization status, permission counts, and current selection - And repeated "--scope" options on the Context list show matching authorized, requestable, and unavailable scopes without filtering Contexts + Then Toolbox lists every stable Context ID, display name, identity type, authorization status, and current selection + And the ordinary list includes Agent-granted scopes, requestable scopes, and published scopes not currently requestable And Context details show the Resource Server supplied description and attributes When the Agent selects the Context by its stable ID Then subsequent GitHub operations use that Context by default @@ -107,18 +107,6 @@ Feature: Realmroot Toolbox command line And the resulting credential offer is stored without a target private key or access token And the command returns only the ready authority without exposing the internal credential binding - @journey:access-context-preflight @entrypoint:agent-request - Scenario: Inspect the selected Context before requesting authority - Given the Resource Server publishes Contexts and their authorized and requestable scopes - When the Agent requests scopes using an explicit, saved, or sole available Context - Then Toolbox reports the Context ID, name, type, and selection source before requesting approval - And it shows every Context's match for the requested scopes without changing the selected Context - And the CLI submits the selected Context and scopes to the server even when the discovery catalog reports missing permissions - And the server rejects scopes outside the controller boundary before creating an approval request - And a controller boundary failure exits with code 1 and preserves the server JSON error body including its code, message, request ID, and details - And pending and completed JSON results preserve the selected Context and selection source - But external account connection or scope expansion can still proceed through controller approval - @journey:task-scoped-access-handoff @entrypoint:agent-request Scenario: Hand an approval link to a remote controller Given the controller is not using the Agent's computer @@ -183,3 +171,10 @@ Feature: Realmroot Toolbox command line Then Wrangler API traffic is routed through the Cloudflare Resource Server And existing Cloudflare credentials are removed from the child environment And Cloudflare asset-upload credentials remain process-local and are accepted only for their matching upload session + + @journey:server-access-error @entrypoint:agent-request + Scenario: Preserve the server decision when requesting authority + When the Agent requests scopes in a selected Context + Then CLI submits the request without a permission precheck + And an unsuccessful server response exits with code 1 + And JSON output preserves the server error code, message, request ID, and details