diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b33fe..f7f99dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,36 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **`c1i access-profiles` — list, get and create access profiles**, which the + API calls request catalogs and routes under `/api/v1/catalogs`. + `access-profiles list` emits NDJSON and auto-paginates; + `access-profiles get ` unwraps the API's + `requestCatalogView.requestCatalog` envelope so the catalog's own keys are at + the top level; `access-profiles create --display-name ` sends only the flags + you pass, so the server's defaults apply to the rest. `--published` and + `--visible-to-everyone` take effect at create time, so a catalog can be + created already published. + + Three server behaviors the commands account for, each verified live. + `access-profiles list` rows carry no member count: the list endpoint reports + `memberCount` as `0` for every catalog while + `access-profiles get` on the same id answers a real count, and the + endpoint takes no parameter (only `page_size`/`page_token`) that could + populate it — so the key is omitted rather than emitted as a zero that reads + like "no members". A catalog's visibility bindings can only be added once it + is published: `POST /api/v1/catalogs/{id}/visibility_bindings` on an + unpublished catalog is a `400`, and so is one on a `--visible-to-everyone` + catalog; unpublished says `catalog must be published to add an access + entitlement`, visible-to-everyone says `catalog is visible to everyone, cannot + add access entitlements`, and the identical call on a catalog published but not + visible to everyone returns `200`. And + delete is a soft delete — the catalog leaves `access-profiles list` while + `access-profiles get` still returns it at exit `0` with `deletedAt` set. + + The sub-resource routes (requestable entitlements, visibility bindings, + bundle automation) and `access-profiles delete`/`update` are not yet wrapped; reach + them through `c1i api`. + - **`c1i tasks close` and `c1i tasks reassign`.** An identity can open a task it cannot resolve -- `approve` and `deny` fail with `action not permitted` when the caller is not on the current policy step, while these two succeed. diff --git a/README.md b/README.md index c1ff483..2af3fc5 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,60 @@ to scope to another user or `--all` for every request in the tenant. `requests get` fetches a single request (the `task_id` returned by `requests create`) as pretty JSON, including its current policy step and outcome. +### Access profiles + +An access profile controls which entitlements are requestable and who can +request them — admins use them to grant birthright access or to open access up +to a chosen audience. + +**The API calls this object a request catalog**, and every path is +`/api/v1/catalogs`, so its JSON keys and ids say "catalog". The spec carries +both names — its `RequestCatalog` schema is tagged +`x-speakeasy-entity: Access_Profile` — so search for either. + +Not to be confused with an app catalog, which is the per-user list of what one +user can request, derived from the access profiles they belong to. + +```sh +c1i access-profiles list [--page-size N] [--page-token TOKEN] [--limit N] +c1i access-profiles get +c1i access-profiles create --display-name [--description ] [--published] [--visible-to-everyone] [--request-bundle] +``` + +`access-profiles create` needs only `--display-name`. Every other flag is omitted from +the request body unless you pass it, so the server's own defaults apply; passing +`--published=false` explicitly still sends `false`. `--published` and +`--visible-to-everyone` both take effect at create time, so a catalog can be +created already published. The new catalog comes back as pretty JSON under +`requestCatalogView`, and `--fields` is never applied to mutation output, so read +the new id from `.requestCatalogView.requestCatalog.id`: + +```sh +CAT_ID=$(c1i access-profiles create --display-name Engineering --published | jq -r .requestCatalogView.requestCatalog.id) +c1i access-profiles get "$CAT_ID" +``` + +Ordering matters once you gate a catalog that is *not* visible to everyone. +Adding a visibility binding (an access entitlement) to an unpublished catalog is +refused with a `400`, `catalog must be published to add an access entitlement`; +publishing it and repeating the same call succeeds. A catalog created with both +`--published` and `--visible-to-everyone` refuses them for a second reason — +`catalog is visible to everyone, cannot add access entitlements` — so create it +published but not visible to everyone if you intend to gate it. + +`access-profiles list` rows do **not** carry a member count: the list endpoint reports +`memberCount` as `0` for every catalog while `access-profiles get` on the same id +reports a non-zero count, so the key is omitted from list rows. `access-profiles get` +also carries the catalog's `accessEntitlements` (its visibility bindings), +empty when there are none, which list rows omit. + +There is no `access-profiles delete` command yet; use `c1i api --path +/api/v1/catalogs/ --method DELETE`. It is a soft delete, verified end to end: +the catalog leaves `access-profiles list`, while `access-profiles get` still returns it at exit +`0` with `deletedAt` set. Because deleted catalogs drop out of the list, a +`deleted_at` in a list row is null in practice; the field is kept to match +the sibling list rows that carry it, not as a signal to filter on. + ### Export ```sh diff --git a/cmd/access_profiles.go b/cmd/access_profiles.go new file mode 100644 index 0000000..b13de86 --- /dev/null +++ b/cmd/access_profiles.go @@ -0,0 +1,23 @@ +package cmd + +import "github.com/spf13/cobra" + +var accessProfilesCmd = &cobra.Command{ + Use: "access-profiles", + Short: "Manage access profiles (the API calls them request catalogs)", + Long: `Access profiles control which entitlements are requestable and who can +request them. Admins create them to grant birthright access or to make access +requestable by a chosen audience. + +The API calls this object a request catalog and routes it under +/api/v1/catalogs, so its JSON keys and the ids you pass here say "catalog". +The two names refer to the same object; the spec tags its schema +"x-speakeasy-entity: Access_Profile". + +Not to be confused with an app catalog, which is the per-user list of what one +user can request, derived from the access profiles they belong to.`, +} + +func init() { + rootCmd.AddCommand(accessProfilesCmd) +} diff --git a/cmd/access_profiles_create.go b/cmd/access_profiles_create.go new file mode 100644 index 0000000..e3f92a7 --- /dev/null +++ b/cmd/access_profiles_create.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var accessProfilesCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create an access profile (pretty JSON)", + Long: `Create an access profile. + +Only --display-name is required. Every other flag is omitted from the request +body unless you pass it, so the server's own defaults apply. + +The new profile is returned as pretty JSON under requestCatalogView (--fields +is not applied to mutation output), so read the id from +.requestCatalogView.requestCatalog.id. + +--published and --visible-to-everyone both take effect at create time: a +profile can be created already published. Ordering matters for the visibility +bindings on a profile published but not visible to everyone — adding an access +entitlement to an unpublished profile is refused with a 400, "catalog must be +published to add an access entitlement", so publish first. + +Example: + CAT_ID=$(c1i access-profiles create --display-name "Engineering" --published | jq -r .requestCatalogView.requestCatalog.id) + c1i access-profiles get "$CAT_ID"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireNonEmpty(cmd, "display-name"); err != nil { + return err + } + + baseURL, err := GetBaseURL() + if err != nil { + return err + } + + body := buildAccessProfileCreateBody(cmd) + + if dryRunActive() { + return printDryRun(cmd, "POST", "/api/v1/catalogs", body) + } + + c, err := newClient(cmd, baseURL) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + data, err := c.Post(cmd.Context(), "/api/v1/catalogs", body) + if err != nil { + return fmt.Errorf("API error: %w", err) + } + + return writeRawObject(cmd, data) + }, +} + +// catalogCreateBoolFlags maps each optional boolean flag to its request-body +// key. Only a flag the caller actually passed is sent, so `--published=false` +// is distinguishable from not asking at all. +var catalogCreateBoolFlags = []struct{ flag, key string }{ + {"published", "published"}, + {"visible-to-everyone", "visibleToEveryone"}, + {"request-bundle", "requestBundle"}, +} + +// buildAccessProfileCreateBody assembles the Create request body from flags. Pure (no +// network / auth) so the dry-run preview and unit tests exercise the same body +// the live request sends. +func buildAccessProfileCreateBody(cmd *cobra.Command) map[string]any { + displayName, _ := cmd.Flags().GetString("display-name") + body := map[string]any{"displayName": displayName} + if cmd.Flags().Changed("description") { + v, _ := cmd.Flags().GetString("description") + body["description"] = v + } + for _, bf := range catalogCreateBoolFlags { + if cmd.Flags().Changed(bf.flag) { + v, _ := cmd.Flags().GetBool(bf.flag) + body[bf.key] = v + } + } + return body +} + +func init() { + f := accessProfilesCreateCmd.Flags() + f.String("display-name", "", "Display name for the new access profile") + f.String("description", "", "Description for the new access profile") + f.Bool("published", false, "Create the access profile already published (omit to leave it unset)") + f.Bool("visible-to-everyone", false, "Let every user see the access profile regardless of its access entitlements; while set, the API refuses to add new ones (\"catalog is visible to everyone, cannot add access entitlements\") (omit to leave it unset)") + f.Bool("request-bundle", false, "Allow requesting every entitlement in the profile at once; the API spec notes \"Your tenant must have the bundles feature to use this\" (omit to leave it unset)") + markRequired(accessProfilesCreateCmd, "display-name") + accessProfilesCmd.AddCommand(accessProfilesCreateCmd) +} diff --git a/cmd/access_profiles_create_test.go b/cmd/access_profiles_create_test.go new file mode 100644 index 0000000..8419b4c --- /dev/null +++ b/cmd/access_profiles_create_test.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func newCatalogCreateFlagCmd() *cobra.Command { + cmd := &cobra.Command{} + f := cmd.Flags() + f.String("display-name", "", "") + f.String("description", "", "") + f.Bool("published", false, "") + f.Bool("visible-to-everyone", false, "") + f.Bool("request-bundle", false, "") + return cmd +} + +// TestBuildCatalogCreateBodyMinimal pins that only displayName is sent when +// nothing else is supplied — no empty description and no defaulted booleans +// leaking into the request, which would silently overwrite the server's own +// defaults for a caller who never asked. +func TestBuildCatalogCreateBodyMinimal(t *testing.T) { + cmd := newCatalogCreateFlagCmd() + _ = cmd.Flags().Set("display-name", "Engineering") + + got := buildAccessProfileCreateBody(cmd) + want := map[string]any{"displayName": "Engineering"} + if !reflect.DeepEqual(got, want) { + t.Errorf("body = %v, want %v", got, want) + } +} + +// TestBuildCatalogCreateBodyFull pins every optional flag flowing through with +// its real JSON type, and that the flag names map to the API's camelCase keys. +func TestBuildCatalogCreateBodyFull(t *testing.T) { + cmd := newCatalogCreateFlagCmd() + for flag, value := range map[string]string{ + "display-name": "Engineering", + "description": "eng access", + "published": "true", + "visible-to-everyone": "true", + "request-bundle": "true", + } { + if err := cmd.Flags().Set(flag, value); err != nil { + t.Fatalf("set --%s: %v", flag, err) + } + } + + got := buildAccessProfileCreateBody(cmd) + want := map[string]any{ + "displayName": "Engineering", + "description": "eng access", + "published": true, + "visibleToEveryone": true, + "requestBundle": true, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("body = %v, want %v", got, want) + } +} + +// TestBuildCatalogCreateBodySendsExplicitFalse pins that an explicitly passed +// --published=false is sent as false rather than dropped: "omitted" and +// "false" are different requests, and only the flag's Changed state tells them +// apart. +func TestBuildCatalogCreateBodySendsExplicitFalse(t *testing.T) { + cmd := newCatalogCreateFlagCmd() + _ = cmd.Flags().Set("display-name", "Engineering") + if err := cmd.Flags().Set("published", "false"); err != nil { + t.Fatalf("set --published: %v", err) + } + + got := buildAccessProfileCreateBody(cmd) + want := map[string]any{"displayName": "Engineering", "published": false} + if !reflect.DeepEqual(got, want) { + t.Errorf("body = %v, want %v", got, want) + } +} + +// TestBuildCatalogCreateBodyExplicitEmptyDescription pins that an explicitly +// passed --description "" reaches the body. The help promises every flag you +// pass is sent; an emptiness test here dropped it, and the same shape copied +// into an update command would silently fail to clear a description. +func TestBuildCatalogCreateBodyExplicitEmptyDescription(t *testing.T) { + cmd := newCatalogCreateFlagCmd() + _ = cmd.Flags().Set("display-name", "Engineering") + _ = cmd.Flags().Set("description", "") + + got := buildAccessProfileCreateBody(cmd) + want := map[string]any{"displayName": "Engineering", "description": ""} + if !reflect.DeepEqual(got, want) { + t.Errorf("body = %v, want %v", got, want) + } +} diff --git a/cmd/access_profiles_docs_test.go b/cmd/access_profiles_docs_test.go new file mode 100644 index 0000000..4d1992e --- /dev/null +++ b/cmd/access_profiles_docs_test.go @@ -0,0 +1,109 @@ +package cmd + +import ( + "regexp" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// flatten collapses whitespace so a phrase still matches when a source wraps it. +func flatten(s string) string { return strings.Join(strings.Fields(s), " ") } + +// orderingQuote is the 400 whose unqualified restatement ("publish and it +// works") is the drift this guard catches. +const orderingQuote = "catalog must be published to add an access entitlement" + +// visibleQuote is the second 400, and the counter-example to that restatement. +const visibleQuote = "catalog is visible to everyone, cannot add access entitlements" + +// qualifier is required verbatim wherever orderingQuote appears. One wording +// across every source is the point: earlier rounds tried to recognise any +// English phrasing that meant the same thing, and each version was defeated by +// a rewording — "whether or not visible to everyone" contains "not visible to +// everyone", so a sentence asserting the opposite satisfied the check. A fixed +// clause has no such hole, and rewording simply fails the test. +const qualifier = "published but not visible to everyone" + +// visibilityBindingSources are the docs stating when a visibility binding is +// accepted. Publishing is necessary but not sufficient. +var visibilityBindingSources = []string{ + "../README.md", + "../CHANGELOG.md", + "agents.md", +} + +// blockSplit breaks a doc where a claim can start: a blank line, a list item +// in any of CommonMark's marker forms, or a table row. Blank lines alone are +// not enough — a list has none between its items, and neither does a table, so +// either would be one block a new claim could borrow a distant clause from. +var blockSplit = regexp.MustCompile(`\n\s*\n|\n\s*(?:[-*+]|\d+[.)])\s|\n\s*\|`) + +// fencedBlock matches a fenced code block in either marker form. A transcript +// of the server's error is an example, not a claim, and cannot carry +// explanatory prose, so requiring the clause inside one would fail on correct +// docs. Indented code blocks are not exempt; this repo fences. +var fencedBlock = regexp.MustCompile("(?s)```.*?```|(?s)~~~.*?~~~") + +func TestVisibilityBindingClaimStaysQualified(t *testing.T) { + if len(visibilityBindingSources) == 0 { + t.Fatal("no sources to check — this guard would prove nothing") + } + + for _, path := range visibilityBindingSources { + checkQualified(t, path, readDocFile(t, path)) + } + checkQualified(t, "access-profiles create help", + accessProfilesCreateCmd.Long+"\n\n"+flagUsages(accessProfilesCreateCmd)) +} + +// checkQualified requires both quotes somewhere in the source, and the +// qualifier in EVERY block that states the ordering — not just the first. +func checkQualified(t *testing.T, path, raw string) { + t.Helper() + + // Quotes may appear anywhere, transcripts included; claims may not. + whole := flatten(raw) + for _, quote := range []string{orderingQuote, visibleQuote} { + if !strings.Contains(whole, quote) { + t.Errorf("%s no longer quotes %q", path, quote) + } + } + + stating := 0 + for _, block := range blockSplit.Split(fencedBlock.ReplaceAllString(raw, ""), -1) { + flat := flatten(block) + if !strings.Contains(flat, orderingQuote) { + continue + } + stating++ + if !strings.Contains(strings.ToLower(flat), qualifier) { + t.Errorf("%s states the ordering without the exact clause %q in the same block; "+ + "publishing alone is not sufficient. Block begins: %q", path, qualifier, excerpt(flat)) + } + } + if stating == 0 { + t.Errorf("%s: no block states the ordering, so nothing was checked", path) + } +} + +// flagUsages concatenates a command's flag descriptions. One quote this guard +// checks is in a flag's help, not in Long. +func flagUsages(cmd *cobra.Command) string { + var b strings.Builder + cmd.Flags().VisitAll(func(f *pflag.Flag) { + b.WriteString(f.Usage) + b.WriteString("\n\n") + }) + return b.String() +} + +// excerpt trims a block to something short enough to name it in a failure. +func excerpt(flat string) string { + if len(flat) > 90 { + return flat[:90] + "…" + } + return flat +} diff --git a/cmd/access_profiles_get.go b/cmd/access_profiles_get.go new file mode 100644 index 0000000..9cf474e --- /dev/null +++ b/cmd/access_profiles_get.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + + "github.com/ConductorOne/c1i/internal/client" + "github.com/spf13/cobra" +) + +var accessProfilesGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a single access profile by ID (pretty JSON)", + Long: `Get a single access profile by ID. + +The API wraps the catalog in requestCatalogView.requestCatalog; the envelope is +unwrapped before printing, so the catalog's own keys (id, displayName, +published, …) are at the top level, beside the view's own siblings +(memberCount, accessEntitlementsPath, createdByUserPath) and the response's +top-level expanded. + +A get carries two things "access-profiles list" rows leave out: the catalog's +accessEntitlements — the visibility bindings that decide who can see it, empty +when there are none — and a memberCount, which the list endpoint reports as 0 +for every catalog.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + baseURL, err := GetBaseURL() + if err != nil { + return err + } + + c, err := newClient(cmd, baseURL) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + data, err := c.Get(cmd.Context(), client.Path("/api/v1/catalogs/%s", args[0]), nil) + if err != nil { + return fmt.Errorf("API error: %w", err) + } + + return writeResource(cmd, data, "id") + }, +} + +func init() { + accessProfilesCmd.AddCommand(accessProfilesGetCmd) +} diff --git a/cmd/access_profiles_list.go b/cmd/access_profiles_list.go new file mode 100644 index 0000000..614a1dd --- /dev/null +++ b/cmd/access_profiles_list.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/spf13/cobra" +) + +// catalogListItem is the subset of the RequestCatalogView surfaced in +// `access-profiles list` rows. The catalog itself is nested one level down, under +// "requestCatalog". +// +// The view's memberCount sibling is deliberately not read: this endpoint +// reports it as "0" for every catalog, and takes no parameter that would +// populate it. `access-profiles get` reports a non-zero count on the ones checked. +type catalogListItem struct { + RequestCatalog struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Published bool `json:"published"` + VisibleToEveryone bool `json:"visibleToEveryone"` + RequestBundle bool `json:"requestBundle"` + DeletedAt string `json:"deletedAt"` + } `json:"requestCatalog"` +} + +// catalogRow flattens a catalogListItem into the NDJSON output row. The three +// booleans stay bools, so `jq 'select(.published)'` means what it reads as, and +// deleted_at is nil, not "", on a live catalog — see CLAUDE.md's row-fidelity +// convention. +func catalogRow(c catalogListItem) map[string]any { + return map[string]any{ + "id": c.RequestCatalog.ID, + "display_name": c.RequestCatalog.DisplayName, + "description": c.RequestCatalog.Description, + "published": c.RequestCatalog.Published, + "visible_to_everyone": c.RequestCatalog.VisibleToEveryone, + "request_bundle": c.RequestCatalog.RequestBundle, + "deleted_at": nilIfEmpty(c.RequestCatalog.DeletedAt), + } +} + +var accessProfilesListCmd = &cobra.Command{ + Use: "list", + Short: "List access profiles (NDJSON output)", + RunE: func(cmd *cobra.Command, args []string) error { + baseURL, err := GetBaseURL() + if err != nil { + return err + } + + c, err := newListClient(cmd, baseURL) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + requestedPageSize := pageSizeFlag(cmd) + pageToken, _ := cmd.Flags().GetString("page-token") + manualPaging := cmd.Flags().Changed("page-token") + limit := getIntFlag(cmd, "limit") + + enc := newEmitter(cmd) + for !limitReached(enc.Written(), limit) { + pageSize := requestedPageSize + if !enc.Filtered() { + pageSize = effectivePageSize(requestedPageSize, limit, enc.Written()) + } + params := map[string]string{ + "page_size": strconv.Itoa(pageSize), + } + if pageToken != "" { + params["page_token"] = pageToken + } + + data, err := c.Get(cmd.Context(), "/api/v1/catalogs", params) + if err != nil { + return fmt.Errorf("API error: %w", err) + } + + var resp struct { + List []catalogListItem `json:"list"` + NextPageToken string `json:"nextPageToken"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + for _, item := range resp.List { + _ = enc.Encode(catalogRow(item)) + if limitReached(enc.Written(), limit) { + return nil + } + } + + if resp.NextPageToken == "" || manualPaging { + break + } + pageToken = resp.NextPageToken + } + + return nil + }, +} + +func init() { + addPaginationFlags(accessProfilesListCmd) + accessProfilesCmd.AddCommand(accessProfilesListCmd) +} diff --git a/cmd/access_profiles_list_test.go b/cmd/access_profiles_list_test.go new file mode 100644 index 0000000..7906b8b --- /dev/null +++ b/cmd/access_profiles_list_test.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "testing" +) + +// catalogViewJSON renders one `access-profiles list` row as the API sends it: the +// catalog nested under "requestCatalog", memberCount a sibling encoded as a +// string. +func catalogViewJSON(published, visibleToEveryone, requestBundle bool) string { + return fmt.Sprintf(`{ + "requestCatalog": { + "id": "cat1", + "displayName": "Engineering", + "description": "eng access", + "deletedAt": null, + "published": %t, + "visibleToEveryone": %t, + "requestBundle": %t + }, + "memberCount": "0" + }`, published, visibleToEveryone, requestBundle) +} + +// TestCatalogRowKeepsRealJSONTypes pins that each boolean stays a bool AND +// lands under the right key. Stringifying one breaks NDJSON consumers +// silently: every non-empty string is truthy, so `jq 'select(.published)'` +// would match a "false". +// +// The cases are one-hot — exactly one field true per case — because three +// booleans cannot all differ. An all-true fixture would let any two of the +// three be cross-wired and still pass; this way every pairwise swap flips an +// assertion in at least one case. +func TestCatalogRowKeepsRealJSONTypes(t *testing.T) { + tests := []struct { + name string + published, visibleToEveryone, reqBundle bool + }{ + {name: "published only", published: true}, + {name: "visible to everyone only", visibleToEveryone: true}, + {name: "request bundle only", reqBundle: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var item catalogListItem + body := catalogViewJSON(tt.published, tt.visibleToEveryone, tt.reqBundle) + if err := json.Unmarshal([]byte(body), &item); err != nil { + t.Fatalf("unmarshal: %v", err) + } + row := catalogRow(item) + + for key, want := range map[string]bool{ + "published": tt.published, + "visible_to_everyone": tt.visibleToEveryone, + "request_bundle": tt.reqBundle, + } { + v, ok := row[key] + if !ok { + t.Fatalf("row has no %s key", key) + } + b, ok := v.(bool) + if !ok { + t.Fatalf("%s has type %T, want bool", key, v) + } + if b != want { + t.Errorf("%s = %v, want %v", key, b, want) + } + } + + if row["id"] != "cat1" || row["display_name"] != "Engineering" { + t.Errorf("row = %#v, want the nested requestCatalog fields hoisted", row) + } + }) + } +} + +// TestCatalogRowOmitsMemberCount pins that the view's memberCount does not +// reach the row. The list endpoint reports it as "0" for every catalog, so a +// member_count key here would read as "no members" for all of them and +// `jq 'select(.member_count > 0)'` would never match. +func TestCatalogRowOmitsMemberCount(t *testing.T) { + var item catalogListItem + if err := json.Unmarshal([]byte(catalogViewJSON(true, true, true)), &item); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if v, ok := catalogRow(item)["member_count"]; ok { + t.Errorf("row has member_count = %#v; the list endpoint does not populate it, so it must be omitted", v) + } +} + +// TestCatalogRowDeletedAtIsNullNotEmptyString pins that deleted_at is untyped +// nil, not "", on a live catalog — "" is truthy in jq and would make +// `jq 'select(.deleted_at)'` match every row. +func TestCatalogRowDeletedAtIsNullNotEmptyString(t *testing.T) { + tests := []struct { + name string + deletedAt string + want any + }{ + {name: "live catalog", deletedAt: "", want: nil}, + {name: "deleted catalog", deletedAt: "2026-01-02T03:04:05Z", want: "2026-01-02T03:04:05Z"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var item catalogListItem + item.RequestCatalog.ID = "cat1" + item.RequestCatalog.DeletedAt = tt.deletedAt + + got, ok := catalogRow(item)["deleted_at"] + if !ok { + t.Fatal("row has no deleted_at key") + } + if tt.want == nil { + if got != nil { + t.Fatalf("deleted_at = %#v, want untyped nil", got) + } + return + } + if got != tt.want { + t.Errorf("deleted_at = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/agents.md b/cmd/agents.md index 0e46753..e192ff3 100644 --- a/cmd/agents.md +++ b/cmd/agents.md @@ -114,10 +114,11 @@ Both share the same error classification: `c1i api` surfaces the same typed errors and the same exit codes as any other command, so the table below applies either way. -`c1i api` is the right tool when no first-class command exists yet. Two -known gaps: access reviews (`/api/v1/access_review*`) and the entitlement -*proxy binding* path (a different object from `mcp bindings` — see `c1i docs -guide delegate-entitlement-provisioning`). Otherwise, discover. +`c1i api` is the right tool when no first-class command exists yet. Known +gaps: access reviews (`/api/v1/access_review*`), the entitlement *proxy +binding* path (a different object from `mcp bindings` — see `c1i docs guide +delegate-entitlement-provisioning`), and the catalog sub-resources +(`/api/v1/catalogs/{id}/…`) plus catalog delete/update. Otherwise, discover. The cobra tree never drifts from what's implemented. Step down it with `--help` at each level: @@ -145,7 +146,10 @@ except the MCP admin endpoints (`mcp_tools`, `mcp_toolsets`, auto-detection picks the wrong array. GET and DELETE refuse a body by default; the few endpoints that need one on DELETE (e.g. `remove-membership`) want `--allow-delete-body`. The UI's "campaign" is the API's access review — a -campaign ID from a URL is the access review `id` directly. +campaign ID from a URL is the access review `id` directly, and the UI's "access +profile" is the API's catalog: `c1i access-profiles list`, `/api/v1/catalogs`, whose +`RequestCatalog` schema is tagged `x-speakeasy-entity: Access_Profile` in the +spec. Search for both names. ## Reading output @@ -313,6 +317,23 @@ Two things are irreversible in ways their `--help` doesn't make obvious: `--tool-state`; the API doesn't compute a count without a state filter, so a filterless search omits the key rather than showing a 0 that would look identical to a server with no tools. +- `access-profiles list` rows carry no member count on purpose. The list endpoint + reports `memberCount` as `0` for every catalog while `access-profiles get` on the + same id reports a non-zero count, so the key is dropped rather than emitted + as a zero that reads like "no members". Use `c1i access-profiles get ` + for the count, and for the catalog's `accessEntitlements` (always present, + empty when there are none), which list rows also omit. +- A catalog's visibility bindings can only be added after it is published: + `POST /api/v1/catalogs/{id}/visibility_bindings` on an unpublished catalog + is a `400`, `catalog must be published to add an access entitlement`; on one + created with `--visible-to-everyone` it is a `400`, + `catalog is visible to everyone, cannot add access entitlements`. A + catalog published but not visible to everyone accepts them immediately. +- There is no `access-profiles delete` yet; delete via `c1i api --path + /api/v1/catalogs/ --method DELETE`. It is a soft delete: the catalog + leaves `access-profiles list`, while `access-profiles get` still returns it at exit `0` + with `deletedAt` set. So a `deleted_at` in an `access-profiles list` row is null in + practice — don't read the null as "not deleted", check with a get. ## Carry forward diff --git a/cmd/get_unwrap_guard_test.go b/cmd/get_unwrap_guard_test.go index cd091a4..2b4ca92 100644 --- a/cmd/get_unwrap_guard_test.go +++ b/cmd/get_unwrap_guard_test.go @@ -146,6 +146,19 @@ func getUnwrapCases() []getUnwrapCase { payloadPath: []string{"profile"}, wantKeys: []string{"id", "appEntitlementId"}, }, + { + // The catalog sits two levels down and memberCount rides beside + // it on the view, so a hoist that took only requestCatalogView + // would strip the id and one that took only requestCatalog would + // drop the count. + name: "access-profiles get", + cmd: accessProfilesGetCmd, + args: []string{"cat-1"}, + idKey: "id", + body: `{"requestCatalogView":{"requestCatalog":{"id":"cat-1","displayName":"Engineering","published":true},"memberCount":"7","createdByUserPath":"","accessEntitlementsPath":""},"expanded":[{"id":"ent-1"}]}`, + payloadPath: []string{"requestCatalogView", "requestCatalog"}, + wantKeys: []string{"id", "displayName", "published", "memberCount", "createdByUserPath", "accessEntitlementsPath", "expanded"}, + }, { name: "mcp servers catalog get", cmd: mcpServersCatalogGetCmd, diff --git a/cmd/list_pagination_test.go b/cmd/list_pagination_test.go index 0ba2939..a02b682 100644 --- a/cmd/list_pagination_test.go +++ b/cmd/list_pagination_test.go @@ -192,6 +192,14 @@ func listPaginationCases() []listPaginationCase { page: flatPage("list", "id"), rowIDs: idRows("id"), }, + { + name: "access-profiles list", + cmd: accessProfilesListCmd, + method: http.MethodGet, + wantPath: "/api/v1/catalogs", + page: nestedPage("list", "requestCatalog", "id"), + rowIDs: idRows("id"), + }, { name: "connectors list", cmd: connectorsListCmd,