Skip to content

feat: CLI resource commands accept API keys - #272

Draft
nicknisi wants to merge 30 commits into
fix/account-plane-forbidden-and-slim-team-queryfrom
riker/320-in-workos-cli-make-the-resource-commands
Draft

nicknisi wants to merge 30 commits into
fix/account-plane-forbidden-and-slim-team-queryfrom
riker/320-in-workos-cli-make-the-resource-commands

Conversation

@nicknisi

Copy link
Copy Markdown
Member

Why

In workos/cli, make the resource commands that today require the WorkOS dashboard session (workos organization, user, role, and every other command gated the same way) work with an API key. When the active profile is an unclaimed environment, or an explicit API key is given (WORKOS_API_KEY or the equivalent flag), back those commands with the WorkOS REST API using that key instead of failing with "uses your WorkOS dashboard session and does not accept API keys". Keep the existing dashboard-session path for signed-in users. JSON output shapes must be identical across both paths (follow "JSON Output Conventions" in CLAUDE.md and extend/use scripts/parity-smoke.ts). Update the auth_required error copy and the dashboardSessionRecovery hints so they no longer say these commands reject API keys. Open a draft PR whose description includes a security read on key scope: one-shot keys from workos env provision / the installer are full-scope, so say what an agent holding one can now do through these commands and whether anything should be restricted. Proof: run the real commands from source against a freshly provisioned unclaimed environment (workos env provision) and paste the workos organization list and workos user list output (API key redacted) into the PR description; keep the existing tests passing.

Decided

  • Before I write code: please confirm (or change) these decisions. D1 Scope. Back every env-scoped resource command with REST in this one PR (organization 5, user 4, role 8, permission 5, membership 7, invitation 5, session 2, org-domain 4, webhook 3, portal 1, event 1, feature-flag 6, config 3 = abou… → All five are confirmed, with one adjustment on D3. D1. Back every env-scoped resource command in this one PR. - The user's goal says "workos organization, user, role, and every other command gated the same way". That's job 320's brief, from his Pi session today, so the smaller split would … (Riker's call)
  • Should I update the docs that now describe the old behavior? Four spots: README.md lines 149, 310–312 and 688, plus CLAUDE.md line 16 (the "Non-TTY Behavior → Auth" bullet). Each says resource commands use only the dashboard session and that WORKOS_API_KEY applies only to workos api and the still-… → Take (a). Update all four spots in this PR as one docs commit: - README.md 149, 310–312 and 688; - CLAUDE.md:16. Why: the user's goal asks to update the copy "so they no longer say these commands reject API keys". These lines make exactly that claim, so fixing them carries out the asked-for chan… (Riker's call)
  • Job 320's review found 7 problems to fix ([blocker] Tests: Dashboard regression guards were deleted). Send its worker back for one round to fix them? → Yes: one fix round, with what the review found as its feedback. (Riker's call)
  • The review's two remaining blockers ask me to restore code that exists only on fix!: surface account-plane FORBIDDEN and slim the team projects query #270's branch. Which do you want? (a) Keep the branch on origin/main as the brief ordered. I change nothing for these two findings and the reviewer re-bases its diff on a3cb418. (b) Carry fix!: surface account-plane FORBIDDEN and slim the team projects query #270's two commits (dc0cb24, c897… → Take (a). Change nothing for these two findings. The brief settles it: "Base on origin/main", with fix!: surface account-plane FORBIDDEN and slim the team projects query #270's work left to fix!: surface account-plane FORBIDDEN and slim the team projects query #270. Both findings are false positives. The job was created while the repo was on fix!: surface account-plane FORBIDDEN and slim the team projects query #270's branch (c897a3e), and Riker's review diffs against that starting point rather than origin/m… (Riker's call)
  • Its check passed, but its review still holds it back: [blocker] Tests: Existing session-path regression tests were deleted — src/catalog/no-graphql-leak.spec.ts:127; [blocker] Tests: Target-mutation test passes without either target write — src/commands/api-key-auth.spec.ts:667; [blocker] Tests: Liv… → A: Try another way: a fix round on what the review found, that knows what I tried (the author's answer)
  • Job 320's review found 4 problems to fix ([blocker] Tests: Deletes tests that catch reverted dashboard fixes). Send its worker back for one round to fix them? → Yes: one fix round, with what the review found as its feedback. (Riker's call)
  • Its check passed, but its review still holds it back: [blocker] Tests: Deletes the slim environment-query regression test alongside its fix — src/catalog/no-graphql-leak.spec.ts:133; [blocker] Signed-in environment resolution loses its protective query — src/catalog/operation.ts:78; [blocker] Domain… → (withdrawn: the job moved on) (the author's answer)
  • Job 320's review found 3 problems to fix ([blocker] Tests: Existing dashboard-session regression tests were deleted). Send its worker back for one round to fix them? → Yes: one fix round, with what the review found as its feedback. (Riker's call)
  • Its check passed, but its review still holds it back: [blocker] Tests: Existing dashboard regression tests are deleted alongside their fixes — src/catalog/no-graphql-leak.spec.ts:125; [blocker] Signed-in environment lookup fix is reverted — src/catalog/operation.ts:77 → A: Try another way: a fix round on what the review found, that knows what I tried (the author's answer)
  • Job 320's review found 2 problems to fix ([blocker] Tests: Dashboard regression tests are deleted alongside their fixes). Send its worker back for one round to fix them? → Yes: one fix round, with what the review found as its feedback. (Riker's call)
  • Its check passed, but its review still holds it back: [blocker] Tests: Dashboard regression tests are deleted alongside their fixes — src/catalog/no-graphql-leak.spec.ts:125; [blocker] Signed-in environment lookup fix is reverted — src/catalog/operation.ts:77 → A: Try another way: a fix round on what the review found, that knows what I tried (the author's answer)
  • Job 320's review found 2 problems to fix ([blocker] Tests: Dashboard regression tests are deleted alongside their fixes). Send its worker back for one round to fix them? → Yes: one fix round, with what the review found as its feedback. (Riker's call)
  • Its check passed, but its review still holds it back: [blocker] Tests: Dashboard regression tests are deleted alongside their fixes — src/catalog/no-graphql-leak.spec.ts:125; [blocker] Reverts the safe signed-in environment lookup — src/catalog/operation.ts:77 → A: Try another way: a fix round on what the review found, that knows what I tried (the author's answer)
  • Job 320's review found 2 problems to fix ([blocker] Tests: Dashboard regression tests are deleted alongside their fixes). Send its worker back for one round to fix them? → Yes: one fix round, with what the review found as its feedback. (Riker's call)
  • The review has now flagged the same two fix!: surface account-plane FORBIDDEN and slim the team projects query #270 findings for six-plus rounds. Each time I've declined them with the same evidence, per your option (a). What should I do this round? (a) Decline again with no change. The loop continues until the review's base is set to a3cb418 instead of c897a3e. (b) Car… → Take (c). Stop here and hand back as done at 1654302, with no code change, no more runs and no more declines-by-fix-round for these two findings. - Why: the user's own revision already settles them: "Ignore the review's no-graphql-leak.spec.ts and operation.ts findings: they come from the wrong … (Riker's call)
  • Its publish failed: I didn't push job 320's work: its branch isn't the code my check passed on. Nothing was pushed; look at it, then use open_pr. My record of what I pushed didn't move, so I can't confirm whether anything new went out. → A: Publish it again now (the author's answer)

Not checked

  • No record of (test -d node_modules || bun install --frozen-lockfile >/dev/null 2>&1) && timeout 480 bunx vitest run src/lib/command-auth.spec.ts src/commands -t "with an API key" running on this code.
  • Read the PR description's security section on key scope, and decide whether anything an agent holding a full-scope one-shot key can now do through these commands should be restricted.
  • Check the pasted workos organization list and workos user list output from a freshly provisioned unclaimed environment. If the worker couldn't provision one, the PR says so plainly.

Opened by Riker (job 320).


Riker job 320: CLI resource commands accept API keys

  • Check: (test -d node_modules || bun install --frozen-lockfile >/dev/null 2>&1) && time…, not yet checked on this head (fab3c63)
  • Review: none on this head yet
  • Open it: riker job 320 · riker watch 320
  • Riker: 0.72.0 (build 6b3c2c3)

Environment-scoped resource commands now take an API-key path when
--api-key or WORKOS_API_KEY is given, or the active profile is an
unclaimed environment. Signed-in users without an explicit key keep the
dashboard session path unchanged.

The path is chosen in one seam (resolveEnvScopedAuth in
dashboard-operation.ts). On it, each catalog operation runs through a
REST adapter that returns the dashboard operation's data shape, so the
commands' existing shapers produce the --json output. Operations with no
REST equivalent exit with a structured api_key_unsupported error.

This commit adds the seam, the --api-key flag on the resource command
groups, the organization/org-domain adapters, and rewords the API-key-set
auth copy, which no longer says these commands reject API keys.
REST adapters for user list/get/update/delete and session list/revoke.
Identities come from the user's organization memberships, with role IDs
and names resolved from their slugs; user get adds auth factors. Fields
REST has no source for (sessionCount, hasPassword, emailVerifiedAt, a
factor's lastVerifiedAt) are null rather than invented; the human view
still reports verification from REST's email_verified flag.
REST adapters for membership list/get/create/update/delete/deactivate/
reactivate and invitation list/get/send/revoke/resend. The CLI's --role
takes a role ID; REST takes a slug, so the ID is resolved through the
roles endpoints first. Organization names on invitations come from one
extra read per organization.
REST adapters for environment and organization roles and for
permissions. REST is slug-keyed while the commands' mutations are
ID-keyed, so the list adapters remember each slug and scope by ID for
the mutation that follows. Role types map EnvironmentRole/OrganizationRole
to the same environment/organization values the session path emits.
…I key

REST adapters for webhook list/create/delete, event list (newest first,
same cursor contract), portal generate-link and feature-flag
list/get/enable/disable. REST can't list a flag's targets, so
add-target/remove-target call the target endpoints directly and flag get
reports accessType and targets as null on this path.
REST adapters for redirect URIs and CORS origins (list, and set as a
create/delete diff) back config redirect add / cors add and authkit
redirect-uris / cors. Where REST lacks a piece the command fails before
any write: no CORS delete, no way to choose the default redirect URI, no
--dry-run. config homepage-url set uses REST's homepage endpoint and
reports applicationId as null. AuthKit logout URIs and branding stay
session-only and fail with api_key_unsupported.
api-key-auth.spec.ts covers when the API-key path is chosen (unclaimed
profile, WORKOS_API_KEY, --api-key), that signed-in users keep the
session otherwise, the unsupported errors, and each command's REST
requests. api-key-parity.spec.ts runs every list/get command on both
paths against the same entity, feeding real dashboard and REST casing,
and requires identical --json apart from the documented null fields in
API_KEY_NULL_FIELDS.
Runs each resource command's list/get on this branch twice, with the
dashboard session and with WORKOS_API_KEY, and requires identical keys
and values. The only allowed differences are the fields in
API_KEY_ACCEPTED (shared with the offline spec via known-gaps.ts), where
the key side must be null.
Narrow flag targets in the human flag view (null on the API-key path),
return the never-typed REST error handler, and type the --api-key
middleware helper without re-returning the yargs instance.
README and CLAUDE.md said the resource commands use only the dashboard
session and that WORKOS_API_KEY applies only to workos api and the
still-REST commands. Describe the new precedence (--api-key, then
WORKOS_API_KEY, then an unclaimed active profile's key, else the
session), the refused --environment-id, the two api_key_unsupported
commands, and the session-only account commands.
- authkit redirect-uris set adds new URIs before deleting dropped ones,
  so a rejected URI or failed request never leaves fewer working URIs.
- Role permission changes tell a 404 from an empty success, never fall
  back to the unchanged role, and confirm the server holds exactly the
  requested permissions before the command reports them.
- Role lookups for memberships search the organization's roles first, so
  an organization role that reuses an environment role's slug wins, as on
  the dashboard.
- REST webhook status maps to the dashboard's Active/InActive and session
  auth methods to its SessionProvider values (from the vendored catalog),
  so enumOut yields the same output on both paths. An OAuth session's
  provider, which REST doesn't name, is null.
Null gaps are now exact leaf paths (user.authenticationFactors[].lastVerifiedAt,
session.impersonator.id, ...) judged by one shared apiKeyParityDiffs():
every object needs the same keys on both sides, arrays the same length,
and every other leaf the same value, so an emptied element or an object
going null fails. The offline spec and parity-smoke --api-key both use it.

parity-smoke --api-key now proves the session side: whoami must succeed
without a key, and every session-side command is pinned to its
environment with --environment-id, which the CLI refuses whenever any API
key applies (including an unclaimed profile's), so the session side can
never quietly run on REST.
The flag test checked outputs and the enable call but not the target
writes, so a target command that printed success without calling REST
would pass. Assert the exact ordered list of writes.
A check now counts only when both sides returned real entities: lists
whose rows each carry their ID, or a non-empty entity object. A missing
envelope key no longer compares undefined with undefined and passes. A
run that compared no entity at all exits 1 and says parity is unproven.
- Full-list reads (permissions, a user's memberships, redirect URIs, CORS
  origins) now request order=desc. Endpoints that default to normal walk
  toward newer records on after, so past 100 permissions the old loop
  stopped at the first page and get/update/delete could report not_found.
- membership delete removes the membership ID the command just resolved
  instead of searching again; the fallback search, and membership/identity
  listings, pass statuses=active,inactive,pending rather than relying on
  REST's default status filter, so a deactivated membership can be listed
  and deleted.
Several REST lists default to the normal order, where the older-page
cursor comes back in before and after walks toward newer records. The
redirect URI read relied on after to detect more than one page, so
config redirect add could merge over a truncated list and the setter
would delete the URIs it never saw. Every key-path list now sends an
explicit order (the user's --order, else desc: same newest-first rows),
and the redirect read defaults to the dashboard's 100.

Tests: config redirect add refuses past one page with no writes; every
paginated list sends an order; config cors add keeps an existing origin
and posts only the new one.
…n API key

The key path mapped every REST domain's verificationContent to null,
dropping the challenge REST returns as verification_prefix and
verification_token. Build it the way the dashboard does
(OrganizationDomain.verificationContent: `<prefix>=<token>`, prefix
defaulting to verification_token, null without a token), so org list/get,
org-domain get, create and verify carry it in the same field. Create
includes it because REST adds domains pending; human create output now
prints the record when there is one.

verificationContent is no longer a documented gap. subdomain is listed
instead: REST doesn't return it and the dashboard derives it with a
public-suffix parser this CLI doesn't ship.

Tests (fail on the old mapping): org-domain get/verify parity against the
dashboard's challenge, the default-prefix case, and create/verify output.
Full-list reads stopped at 50 pages and returned what they had. Callers
treat the result as complete (slug lookups, list replaces), so a partial
list could report a permission missing or act on the wrong set. Exit with
list_too_long instead.
The in-process test set the flag with setResourceApiKeyFlag(), skipping
yargs and the resource-group middleware. Drive bin.ts as a subprocess
against a local HTTP stub (temp HOME, file-only storage via the
insecure-storage preload) and assert the Authorization header: --api-key
beats WORKOS_API_KEY and an unclaimed profile, WORKOS_API_KEY beats the
profile, the profile's key is used last, and --environment-id with a key
is refused before any request.
parity-smoke --api-key passed the pagination cursor names to
apiKeyParityDiffs, which skipped them at every depth, so a changed or
missing metadata.before / metadata.after / metadata.pagination still
passed. Both callers only ever pass the entity or its rows (the cursors
live in the envelope, outside the comparison), so the gate drops the
exemption parameter entirely and compares every key inside the entity.
A test covers cursor-named metadata keys and fails on the old gate.
…y path

REST names a membership's roles by slug. The dashboard reports them by
role ID on membership records (UserlandUserOrganizationMembership.role /
.roles: membership list --user, get, update) and by display name on an
organization's identities (role { name } in membership list --org). The
key path passed REST's {slug} through, so it printed "member" where the
dashboard prints "role_..." or "Member". Resolve each slug through the
organization's roles (then the environment's) and emit the same values.

The parity fixtures now use what those dashboard queries actually select
(role IDs; role { name }), so the old mapping fails them. A role list
that 404s now reads as empty, like an organization's.
- An empty WORKOS_API_KEY now counts as unset, so the key in use and the
  reported key source always agree. --api-key mode also proves the key
  side is on the key before comparing (the CLI must refuse
  --environment-id there), so it can't compare the session to itself.
- The default branch-vs-main mode no longer hands WORKOS_API_KEY to the
  branch's resource commands, which now take the key path when it is set:
  they run without it (still-REST commands keep it), and are pinned to the
  session's environment from whoami, so an unclaimed profile's key is
  refused instead of silently used. Env-scoped NEW checks get the pinned
  --environment-id at run time.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant