fix(auth): authorize API keys solely by their own allowlist (no owner-role widening)#4180
Merged
Merged
Conversation
b30b57f to
bdda71c
Compare
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>
bdda71c to
d703396
Compare
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
This was referenced Jun 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a broken-access-control / privilege-escalation bug (High): an API key whose owner is an org
admin/ownersilently inherited full org access — the admin/owner role bypass fired before the key'spermissionsallowlist 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 viaAPI_KEY_CREATE).The model
An API key is authorized solely by its own
permissionsallowlist. 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.checkResourcedispatches to one of two self-contained codepaths by principal type — so each is simple to reason about in isolation:checkApiKeyAccess: the key's allowlist is the entire decision (checkApiKeyPermission). No role, no admin/owner bypass, no basic-usage, no Better Auth.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.hasPermissionmirrors the same split.Other changes
defaultPermissionsfrom the Better Auth apiKey plugin;API_KEY_CREATEnow requirespermissions.dispatch-run.ts) + dev-link (dev-link-session.ts) act as the user →{ "*": ["*"] }; org-fs mount (provisioning.ts) →{ self: ["*"] }(which covers itsORG_FS_READ/WRITEchecks directly). The<org>_selfconnection 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
permissionsand are enforced against exactly that.Tests
apps/mesh/src/auth/api-key-permissions.test.ts—checkApiKeyPermission.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 onMONITORING_STATS,API_KEY_CREATE, and a basic-usage tool outside its allowlist; wildcard key keeps access; create withoutpermissions→ 400.tsc+oxlintclean. Unit tests (auth + core) green except the pre-existing DB-gatedcontext-factory.integration.test.ts. Playwright e2e validated by CI.Related / follow-up
🤖 Generated with Claude Code