Skip to content

feat(saas): scoped API keys with read, execute, lifecycle, and admin grants#824

Open
stubbi wants to merge 2 commits into
mainfrom
feat/scoped-api-keys-784
Open

feat(saas): scoped API keys with read, execute, lifecycle, and admin grants#824
stubbi wants to merge 2 commits into
mainfrom
feat/scoped-api-keys-784

Conversation

@stubbi

@stubbi stubbi commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Thinking Path

  • Mitos boots Firecracker microVMs and forks them via copy-on-write snapshots, exposed through CRDs and, for the hosted offering, a public API gateway that terminates customer key auth.
  • The subsystem here is the SaaS front door: the key service (internal/saas/keys.go, model.go) and the gateway authz path (internal/saas/gateway.go), layered above the internal per-sandbox token plane.
  • Today a hosted API key is org-wide all-or-nothing: a key that can list can also create, fork, and terminate. A team embedding Mitos cannot hand out a browser-safe or CI-safe key with a blast radius smaller than the org.
  • This is the first step toward the capability-budget model in docs/api/v2-spec.md, and it moves the security surface, so it needs a threat-model delta in the same change.
  • This pull request adds mint-time scopes (read, execute, lifecycle, admin), enforces them in the gateway with an LLM-legible 403, and keeps every existing key working (a scopeless key stays full-access; a key minted without scopes defaults to full).
  • The benefit is least-privilege API keys: a caller can issue a key that does strictly less than the whole org, shrinking the blast radius of an embedded or leaked credential.

Linked Issues or Issue Description

Closes #784

What Changed

  • Scope model (internal/saas/model.go): added ScopeExecute, ScopeLifecycle, ScopeAdmin alongside the existing read and legacy sandboxes scopes; a one-way implication graph (scopeSatisfies) where every resource scope implies read, execute and lifecycle are orthogonal, admin is orthogonal, and legacy sandboxes satisfies read/execute/lifecycle. HasScope now treats a scopeless key as full-access. Added FullScopes() and a closed knownScopes vocabulary.
  • Mint path (internal/saas/keys.go): CreateKey defaults an empty scope request to the full set and rejects an unknown scope with the new ErrUnknownScope.
  • Gateway authz (internal/saas/gateway.go): requiredScopeFor maps read ops to read, runtime (exec/files/run_code) to execute, and every mutating verb to lifecycle, failing closed to lifecycle for unmapped ops. A scope denial is now an actionable 403 naming the required scope and carrying op plus required_scope in context.
  • CLI (internal/agentcli/auth.go): auth keys create --scopes help now names the scope vocabulary; default is read,execute,lifecycle.
  • Docs: docs/saas/accounts-gateway.md gains an API-key-scopes section; docs/api/v2-spec.md notes scoped keys as the first step toward the capability budget; docs/threat-model.md adds a scoped-API-keys row in section 7b.
  • Tests: internal/saas/scopes_test.go and internal/saas/gateway_scopes_test.go (scope enforcement, backward compat, mint defaults, unknown-scope refusal); updated the fork op scope expectation in gateway_livefork_test.go.

Verification

  • go test ./internal/saas/... ./cmd/gateway/... ./internal/agentcli/... ./cmd/mitos/... (all pass)
  • gofmt -l clean on changed files
  • golangci-lint run ./internal/saas/... ./cmd/gateway/... ./internal/agentcli/... --timeout=5m clean (both default and GOOS=linux)
  • Backward-compat proven: TestLegacyScopelessKeyHasFullAccess and TestGatewayLegacyScopelessKeyRetainsFullAccess (a scopeless key reaches every op); TestCreateKeyDefaultsToFullScopes (empty mint = full)

Risks

Low risk. The change is additive and backward compatible by construction: existing keys carry the legacy sandboxes scope (which still satisfies read/execute/lifecycle) or no scopes (treated as full access), so no current caller loses access. The persisted store shape is unchanged (scopes are already a TEXT[] column in pgstore and a slice in the mem store), so there is no migration. The only behavior change for a NEW key is that naming a narrower scope now restricts it, which is the intended feature.

Model Used

Claude Opus 4.8 (1M context), model id claude-opus-4-8, extended thinking enabled.

Checklist

  • PR title is a conventional commit (feat, fix, docs, ci, chore, refactor, test)
  • Thinking Path traces from project context to this change
  • Model Used is filled in (with version and capability details)
  • Tests added for behavior changes, in the same commit (TDD)
  • Docs updated in the same PR
  • Threat-model delta (docs/threat-model.md) included if the security surface moved
  • Benchmark run (bench/) included if the hot path was touched (N/A: no hot path touched)
  • No em or en dashes introduced anywhere
  • Secret values never logged, in errors, in condition messages, or on host paths
  • No internal/instance-local references (only public #NNN / mitos-run/mitos URLs)
  • Every commit carries a Signed-off-by trailer (git commit -s)

Summary by CodeRabbit

  • New Features
    • Added clearer API key scope support with read, execute, lifecycle, and admin.
    • Requests now derive required scopes per operation and enforce least-privilege “fail closed” behavior for unmapped/unknown operations.
  • Bug Fixes
    • Scope denials now return a 403 with the exact required scope and operation context.
    • Legacy keys with no scopes retain full access for backward compatibility.
  • Documentation
    • Updated gateway/auth and threat model docs to describe scope enforcement behavior.
  • Tests
    • Added coverage for scope mapping, backward compatibility, and unknown-scope rejection.

…grants

Hosted API keys were org-wide all-or-nothing: a key that can list could also
create, fork, and terminate. This adds mint-time SCOPES so a team can embed a
browser-safe or CI-safe key whose blast radius is smaller than the org.

Scopes: read (list/get/status), execute (exec/files/run_code inside an existing
sandbox), lifecycle (create/fork/terminate, pause/resume), admin (org
management: keys, billing). The gateway authz path maps each op to its required
scope and denies a key that lacks it with an LLM-legible forbidden (403) naming
the exact scope and reporting op plus required_scope in context; an unmapped op
fails closed to lifecycle. A one-way implication graph keeps the surface
walkable: every resource scope implies read (no dead end), execute and lifecycle
are orthogonal, admin is orthogonal to the resource scopes, and the legacy
sandboxes scope still satisfies read, execute, and lifecycle.

Backward compatibility is mandatory and enforced: a key stored with NO scopes is
a legacy full-access key that satisfies every scope, and a key minted without
scopes defaults to the full set, so no current caller loses access. An unknown
scope is refused at mint rather than silently minting a key that grants nothing.

The per-resource qualifier (a scope narrowed to a named sandbox or pool) is a
documented follow-up, not this change. Since this moves the security surface,
docs/threat-model.md, docs/saas/accounts-gateway.md, and docs/api/v2-spec.md are
updated in the same change.

Closes #784

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b32777d6-cce5-48fe-b99b-cfea1d59fb1d

📥 Commits

Reviewing files that changed from the base of the PR and between e0a1b98 and 3afe917.

📒 Files selected for processing (1)
  • internal/saas/keys.go

📝 Walkthrough

Walkthrough

Adds scoped API key vocabulary and implication rules, default mint-time scope handling, and gateway authorization enforcement with scope-specific denials. The CLI default scope set, tests, and documentation are updated to match the new behavior.

Changes

Scoped API Keys

Layer / File(s) Summary
Scope vocabulary and implication model
internal/saas/model.go, internal/saas/scopes_test.go
Adds ScopeExecute, ScopeLifecycle, and ScopeAdmin, scope implication evaluation, knownScopes, and FullScopes(); reworks HasScope so empty scopes grant full access and non-empty scopes use implication logic, validated by new tests.
Key creation scope validation and CLI defaults
internal/saas/keys.go, internal/agentcli/auth.go, internal/saas/scopes_test.go
CreateKey defaults empty scopes to FullScopes(), validates requested scopes against knownScopes, rejects unknown scopes via ErrUnknownScope, and persists derived scopes; the CLI --scopes default changes to read,execute,lifecycle with updated help text; tests cover defaulting, rejection, and verify behavior.
Gateway scope enforcement
internal/saas/gateway.go, internal/saas/gateway_livefork_test.go
requiredScopeFor maps ops to read/execute/lifecycle scopes with fail-closed lifecycle fallback; failVerify now returns scope-specific 403 responses naming the required scope and operation context; the fork test expectation is updated to lifecycle.
Gateway scope enforcement tests
internal/saas/gateway_scopes_test.go
New tests validate op-to-scope mapping and permission boundaries for execute, lifecycle, read-only, and legacy scopeless keys against a live gateway with a fake control plane.
Documentation and threat model updates
docs/api/v2-spec.md, docs/saas/accounts-gateway.md, docs/threat-model.md
Updates the Auth section, adds an API key scopes section with an operation-to-scope table and backward-compatibility rules, and adds a threat-model entry describing scoped API key behavior.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Gateway
  participant KeyModel
  participant ControlPlane
  Client->>Gateway: request with op
  Gateway->>Gateway: requiredScopeFor(op)
  Gateway->>KeyModel: HasScope(requiredScope)
  KeyModel-->>Gateway: satisfied / ErrKeyScope
  alt scope satisfied
    Gateway->>ControlPlane: forward request
    ControlPlane-->>Client: response
  else scope denied
    Gateway->>Gateway: failVerify(op, requiredScope)
    Gateway-->>Client: 403 forbidden with required_scope
  end
Loading

Possibly related PRs

  • mitos-run/mitos#611: Also touches hosted gateway routing for the sandbox fork path, which overlaps with the fork authorization scope change here.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, conventional, and accurately summarizes the scoped API key feature.
Description check ✅ Passed The description follows the template with Thinking Path, linked issue, changes, verification, risks, model, and checklist.
Linked Issues check ✅ Passed The PR implements the requested mint-time scopes, gateway enforcement, clear denials, and backward compatibility for legacy keys.
Out of Scope Changes check ✅ Passed The docs, CLI, gateway, and tests all support the scoped-key feature and do not appear unrelated to the issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/scoped-api-keys-784

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/saas/keys.go`:
- Around line 163-177: The scope validation in create key uses knownScopes, but
the error message hardcodes the “valid” list and can drift from the accepted
vocabulary. Update the validation branch in internal/saas/keys.go so the error
text is derived from knownScopes (including the legacy sandboxes scope) rather
than a literal list, keeping the message aligned with the actual accepted
scopes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d109b0c4-f08b-43d7-a654-33b567cbd1df

📥 Commits

Reviewing files that changed from the base of the PR and between f74f4f0 and e0a1b98.

📒 Files selected for processing (10)
  • docs/api/v2-spec.md
  • docs/saas/accounts-gateway.md
  • docs/threat-model.md
  • internal/agentcli/auth.go
  • internal/saas/gateway.go
  • internal/saas/gateway_livefork_test.go
  • internal/saas/gateway_scopes_test.go
  • internal/saas/keys.go
  • internal/saas/model.go
  • internal/saas/scopes_test.go

Comment thread internal/saas/keys.go
…event drift

Address CodeRabbit review on #824: the unknown-scope error string hardcoded the
valid scope list, which could drift from the accepted vocabulary. Build it from
FullScopes() instead. The legacy sandboxes alias stays accepted but is
deliberately not advertised: new keys should carry the finer scopes.

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
@stubbi

stubbi commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

CI note for the reviewer/merger: go-test needed retries because TestLiveCowForkInheritanceAndNoLeak in internal/fork (a userfaultfd KVM test, wpfork_kvm_test.go) flaked with sendmsg uffd: broken pipe / recvmsg: resource temporarily unavailable. That test is unrelated to this change (this PR touches only internal/saas, cmd/gateway, internal/agentcli, and docs) and the same flake is present on the base commit (main f74f4f0) independently. It passed on retry with no code change. All required checks are now green.

@stubbi

stubbi commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Full security review done (token/attenuation code, so extra rigor). The scope split, the single-funnel enforcement, the implication graph, and the hygiene are good work, but two findings block merge as-is:

  1. The "legacy scopeless key = full access" rule grants access, it does not preserve it. The premise that pre-scope keys exist is false: pgstore/migrations/0001_init.sql has scopes TEXT[] NOT NULL DEFAULT '{}' from the very first migration, and git log -S requiredScopeFor shows scope enforcement arrived in the same commit that introduced API keys at all. On main today an empty-scope key satisfies nothing and 403s everywhere; empty-scope keys ARE mintable (the console form lets a PermUseResources member uncheck both boxes). After this PR, every such inert key silently becomes full access INCLUDING admin, which even the legacy sandboxes scope deliberately excludes. Scopeless stored keys should fail closed, or at most equal the legacy sandboxes grant. The tests, docs, and threat-model row encoding the false premise need the same rewrite.

  2. The mint default includes admin. CreateKey expands an empty scope request to FullScopes(), so "uncheck everything" or --scopes "" mints an admin-carrying key for any ordinary member, while the PR's own CLI default is read,execute,lifecycle. Default should match the CLI (no admin); admin should be explicit-request only, and arguably gated on a manage permission.

Also worth fixing in the same pass: the console key form still offers only the old sandboxes/read vocabulary, so the new scopes cannot be minted where the 403 remediation text sends people; and the threat-model row should state the residual that unmapped ops fail toward lifecycle, which is fail-open relative to a future gateway admin surface.

Holding this one. Per CLAUDE.md this is also a named-human-reviewer path.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(saas): scoped API keys with read-only and per-resource grants

1 participant