Skip to content

fix(auth): authorize API keys solely by their own allowlist (no owner-role widening)#4180

Merged
viktormarinho merged 2 commits into
mainfrom
viktormarinho/apikey-scope-enforcement
Jun 28, 2026
Merged

fix(auth): authorize API keys solely by their own allowlist (no owner-role widening)#4180
viktormarinho merged 2 commits into
mainfrom
viktormarinho/apikey-scope-enforcement

Conversation

@viktormarinho

@viktormarinho viktormarinho commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a broken-access-control / privilege-escalation bug (High): an API key whose owner is an org admin/owner silently inherited full org access — the admin/owner role bypass fired before the key's permissions allowlist was ever checked. A "read-only" key minted by an admin could call any tool (API_KEY_CREATE, ORGANIZATION_*, secrets, member mgmt). Found by an agent run and verified in prod (a read-only key minted a new admin-capable key via API_KEY_CREATE).

The model

An API key is authorized solely by its own permissions allowlist. The owner's role never widens it, and there is no membership floor — a key is a capability, not a member. A full-access key carries an explicit wildcard ({ "*": ["*"] } / { self: ["*"] }); a key with no allowlist grants nothing. There is no implicit "default permission" fallback — "a key without a scope" no longer exists.

AccessControl.checkResource dispatches to one of two self-contained codepaths by principal type — so each is simple to reason about in isolation:

  • API keycheckApiKeyAccess: the key's allowlist is the entire decision (checkApiKeyPermission). No role, no admin/owner bypass, no basic-usage, no Better Auth.
  • member (session / MCP OAuth / mesh JWT) → checkMemberAccess: the existing membership-floor + admin/owner bypass + Better Auth path, unchanged.

The flag lives on boundAuth.isApiKeyPrincipal, so REST (/api/:org/tools/:name) and MCP (/mcp) share one signal; createBoundAuthClient.hasPermission mirrors the same split.

Other changes

  • Remove defaultPermissions from the Better Auth apiKey plugin; API_KEY_CREATE now requires permissions.
  • Internal minters that relied on the default now pass an explicit scope: per-run agent MCP key (dispatch-run.ts) + dev-link (dev-link-session.ts) act as the user → { "*": ["*"] }; org-fs mount (provisioning.ts) → { self: ["*"] } (which covers its ORG_FS_READ/WRITE checks directly). The <org>_self connection key was already { self: ["*"] }, so self/loopback (Studio Pack, automations) is unaffected.

Because a key is now exactly its allowlist (not allowlist ∪ basic-usage), internal keys carry a wildcard and are unaffected; user-scoped keys are tightened to precisely what they were scoped to. Browser sessions, MCP OAuth, and mesh JWTs are unchanged.

Prod migration

Internal keys are short-TTL (per-run / org-fs) or 30-day (dev-link) → re-mint with explicit scope, no backfill. Existing user keys keep their stored permissions and are enforced against exactly that.

Tests

  • apps/mesh/src/auth/api-key-permissions.test.tscheckApiKeyPermission.
  • apps/mesh/src/core/access-control.test.ts + context-factory.bound-auth.test.ts — API-key principal does NOT bypass admin and does NOT get the basic-usage floor; member principal still does; wildcard = full; no-allowlist = deny.
  • packages/e2e/tests/apikey-scope-enforcement.spec.ts — black-box HTTP: scoped key → 403 on MONITORING_STATS, API_KEY_CREATE, and a basic-usage tool outside its allowlist; wildcard key keeps access; create without permissions → 400.

tsc + oxlint clean. Unit tests (auth + core) green except the pre-existing DB-gated context-factory.integration.test.ts. Playwright e2e validated by CI.

Related / follow-up

🤖 Generated with Claude Code

@viktormarinho viktormarinho force-pushed the viktormarinho/apikey-scope-enforcement branch from b30b57f to bdda71c Compare June 28, 2026 02:10
@viktormarinho viktormarinho changed the title fix(auth): enforce deliberately-scoped API keys against the owner's admin role fix(auth): authorize API keys solely by their own allowlist (no owner-role widening) Jun 28, 2026
viktormarinho and others added 2 commits June 28, 2026 11:26
A key's owner role no longer widens it: an API key is authorized strictly by its
stored `permissions`. A key an admin scopes to e.g. ORGANIZATION_GET cannot call
any other tool — the privilege escalation reported by an agent run (a read-only
key was minting admin keys via API_KEY_CREATE).

- `AccessControl.checkResource` now dispatches to one of two self-contained
  codepaths by principal type: an **API-key** principal is decided SOLELY by the
  key's allowlist (`checkApiKeyAccess` → `checkApiKeyPermission`) — no role, no
  admin/owner bypass, no basic-usage membership floor, no Better Auth; a
  **member** principal (session / MCP OAuth / mesh JWT) keeps the existing
  membership-floor + role-bypass + Better Auth path (`checkMemberAccess`). The
  flag lives on `boundAuth.isApiKeyPrincipal`, so REST and MCP share one signal.
- `createBoundAuthClient.hasPermission` mirrors the split (API key →
  `checkApiKeyPermission` before any role logic).
- Remove the "default permissions" concept. `API_KEY_CREATE` now requires
  `permissions`, and the three internal minters that relied on the default pass
  an explicit scope: the per-run agent MCP key and the dev-link key act as the
  user (`{ "*": ["*"] }`), the org-fs mount uses `{ self: ["*"] }` (its file ops
  are covered by that scope). "A key without a scope" no longer exists.

A key is therefore exactly its allowlist — not allowlist ∪ basic-usage. Internal
keys carry a wildcard so they are unaffected; user-scoped keys are tightened to
precisely what they were scoped to. Browser sessions, MCP OAuth, and mesh JWTs
are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…role

Black-box HTTP regression: an admin-owned key scoped to AUTOMATION_LIST is denied
MONITORING_STATS, API_KEY_CREATE, and a basic-usage tool outside its allowlist
(403) — a key is a capability, not a member. A wildcard key keeps full access;
API_KEY_CREATE without `permissions` is rejected (400).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@viktormarinho viktormarinho force-pushed the viktormarinho/apikey-scope-enforcement branch from bdda71c to d703396 Compare June 28, 2026 14:26
@viktormarinho viktormarinho merged commit 440436a into main Jun 28, 2026
15 of 16 checks passed
@viktormarinho viktormarinho deleted the viktormarinho/apikey-scope-enforcement branch June 28, 2026 14:49
decocms Bot pushed a commit that referenced this pull request Jun 28, 2026
PR: #4180 fix(auth): authorize API keys solely by their own allowlist (no owner-role widening)
Bump type: patch

- decocms (apps/mesh/package.json): 3.67.0 -> 3.67.1
- @decocms/e2e (packages/e2e/package.json): 0.0.2 -> 0.0.3

Deploy-Scope: server
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.

1 participant