diff --git a/README.md b/README.md index 76f5d71..89e7b17 100644 --- a/README.md +++ b/README.md @@ -135,6 +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 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.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..03da430 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":"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.StatusBadRequest) + _, _ = 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 != 400 || 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/cli/cli.go b/internal/cli/cli.go index aa7322c..8e5a3cf 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -310,6 +310,12 @@ func (a *App) requestCommand() *cobra.Command { } receipt, err := accessService.Request(ctx, server, scopes, details, 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 { + return printErr + } + } return err } return a.printJSON(receipt) 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 c4c7dd7..66c92ca 100644 --- a/internal/cli/contexts.go +++ b/internal/cli/contexts.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "slices" "sort" "strings" "text/tabwriter" @@ -26,10 +27,14 @@ type contextSummary struct { } type contextListItem struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - AccountAuthorizationStatus string `json:"accountAuthorizationStatus"` - Current bool `json:"current"` + 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 { @@ -50,10 +55,21 @@ 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 { + 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{ + 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), }) @@ -100,7 +116,7 @@ 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)}) + 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) @@ -213,7 +229,7 @@ func (a *App) printContexts(result contextResult) error { return nil } w := tabwriter.NewWriter(a.stdout, 0, 4, 2, ' ', 0) - fmt.Fprintln(w, "CURRENT\tID\tNAME\tACCOUNT") + fmt.Fprintln(w, "CURRENT\tID\tNAME\tTYPE\tACCOUNT\tAGENT GRANTED\tREQUESTABLE\tNOT CURRENTLY REQUESTABLE") for _, item := range result.Contexts { current := "" if item.Current { @@ -223,7 +239,7 @@ func (a *App) printContexts(result contextResult) error { if id == "" { id = "-" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", current, id, item.Name, item.AccountAuthorizationStatus) + 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 w.Flush() } @@ -257,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 140e451..0393fea 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 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 @@ -170,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