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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <access-profile-id>` unwraps the API's
`requestCatalogView.requestCatalog` envelope so the catalog's own keys are at
the top level; `access-profiles create --display-name <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.
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <access-profile-id>
c1i access-profiles create --display-name <name> [--description <text>] [--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/<id> --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
Expand Down
23 changes: 23 additions & 0 deletions cmd/access_profiles.go
Original file line number Diff line number Diff line change
@@ -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)
}
96 changes: 96 additions & 0 deletions cmd/access_profiles_create.go
Original file line number Diff line number Diff line change
@@ -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)
}
97 changes: 97 additions & 0 deletions cmd/access_profiles_create_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading