Skip to content

Commit b30b57f

Browse files
viktormarinhoclaude
andcommitted
test(e2e): scoped admin-owned API key cannot escalate past its allowlist
Black-box HTTP regression for the privilege-escalation fix: an admin-owned key scoped to AUTOMATION_LIST is denied MONITORING_STATS and API_KEY_CREATE (403), while a key created with the default permission set still reaches a non-basic-usage tool via the owner's role (not 403) — guarding the default-key fleet (orgfs-<org>) from being retroactively locked down. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent efb0b11 commit b30b57f

1 file changed

Lines changed: 116 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* E2E: a deliberately-scoped API key is capped at its allowlist even when its
3+
* owner is an org admin/owner.
4+
*
5+
* Regression for the privilege-escalation bug where the admin/owner role bypass
6+
* fired before a key's stored `permissions` were checked — so a "read-only" key
7+
* minted by an admin acted with full org power (could call API_KEY_CREATE,
8+
* MONITORING_STATS, etc.). See apps/mesh/src/auth/api-key-permissions.ts.
9+
*
10+
* Contract proven over HTTP only:
11+
* - a key narrowed past the default set is denied an out-of-scope tool (403),
12+
* including the exact escalation (API_KEY_CREATE), and
13+
* - a key created WITHOUT explicit scope (the default permission set, which
14+
* the large `orgfs-<org>` fleet uses) still inherits the owner's role and is
15+
* NOT denied — so the fix doesn't retroactively lock down default keys.
16+
*/
17+
import { signUpViaApi } from "../fixtures/auth-api";
18+
import { expect, newApiContext, test } from "../fixtures/test";
19+
20+
test.describe("API key scope enforcement", () => {
21+
test("an admin-owned scoped key cannot escalate past its allowlist", async ({
22+
playwright,
23+
}) => {
24+
const ownerCtx = await newApiContext(playwright);
25+
const owner = await signUpViaApi(ownerCtx);
26+
const stamp = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
27+
28+
// The owner is admin/owner of their auto-created org. Mint a key DELIBERATELY
29+
// scoped to a single tool. Scoped keys must be created server-side via the
30+
// tool (Better Auth blocks `permissions` on cookie/client requests), and the
31+
// owner's admin role authorizes the cookie-session call here.
32+
const createScoped = await ownerCtx.post(
33+
`/api/${owner.orgSlug}/tools/API_KEY_CREATE`,
34+
{
35+
data: {
36+
name: `scoped-${stamp}`,
37+
permissions: { self: ["AUTOMATION_LIST"] },
38+
},
39+
},
40+
);
41+
expect(
42+
createScoped.ok(),
43+
`API_KEY_CREATE(scoped): HTTP ${createScoped.status()}${await createScoped.text().catch(() => "")}`,
44+
).toBe(true);
45+
const scopedKey = ((await createScoped.json()) as { key?: string }).key;
46+
expect(scopedKey, "scoped key value returned").toBeTruthy();
47+
48+
// A key created WITHOUT explicit scope gets the system default permission
49+
// set. The fleet of default keys (e.g. orgfs-<org>) relies on inheriting the
50+
// owner's role, so this must remain un-enforced.
51+
const createDefault = await ownerCtx.post(
52+
`/api/${owner.orgSlug}/tools/API_KEY_CREATE`,
53+
{ data: { name: `default-${stamp}` } },
54+
);
55+
expect(
56+
createDefault.ok(),
57+
`API_KEY_CREATE(default): HTTP ${createDefault.status()}`,
58+
).toBe(true);
59+
const defaultKey = ((await createDefault.json()) as { key?: string }).key;
60+
expect(defaultKey, "default key value returned").toBeTruthy();
61+
62+
// Bearer-auth context with no session cookie → auth is purely the API key.
63+
const apiCtx = await newApiContext(playwright);
64+
const auth = (key: string) => ({ Authorization: `Bearer ${key}` });
65+
66+
// In allowlist → allowed (the key authenticates and works).
67+
const inScope = await apiCtx.post(
68+
`/api/${owner.orgSlug}/tools/AUTOMATION_LIST`,
69+
{ headers: auth(scopedKey!), data: {} },
70+
);
71+
expect(
72+
inScope.ok(),
73+
`scoped AUTOMATION_LIST: HTTP ${inScope.status()}`,
74+
).toBe(true);
75+
76+
// Out of allowlist AND not a basic-usage tool → DENIED, even though the
77+
// owner is an admin. This is the fix.
78+
const outOfScope = await apiCtx.post(
79+
`/api/${owner.orgSlug}/tools/MONITORING_STATS`,
80+
{ headers: auth(scopedKey!), data: {} },
81+
);
82+
expect(
83+
outOfScope.status(),
84+
`scoped MONITORING_STATS should be 403, got ${outOfScope.status()}`,
85+
).toBe(403);
86+
const deniedBody = (await outOfScope.json()) as { error?: string };
87+
expect(deniedBody.error ?? "").toMatch(
88+
/access denied|permission|forbidden/i,
89+
);
90+
91+
// The exact escalation from the report: a scoped key must not mint keys.
92+
const escalate = await apiCtx.post(
93+
`/api/${owner.orgSlug}/tools/API_KEY_CREATE`,
94+
{ headers: auth(scopedKey!), data: { name: `should-not-work-${stamp}` } },
95+
);
96+
expect(
97+
escalate.status(),
98+
`scoped API_KEY_CREATE should be 403, got ${escalate.status()}`,
99+
).toBe(403);
100+
101+
// Regression guard: the default-permission key still reaches a non-basic-usage
102+
// tool via the owner's admin role (NOT denied). Status may be 200 or a
103+
// downstream error, but never 403.
104+
const defaultReach = await apiCtx.post(
105+
`/api/${owner.orgSlug}/tools/MONITORING_STATS`,
106+
{ headers: auth(defaultKey!), data: {} },
107+
);
108+
expect(
109+
defaultReach.status(),
110+
`default key MONITORING_STATS must not be 403, got ${defaultReach.status()}`,
111+
).not.toBe(403);
112+
113+
await ownerCtx.dispose();
114+
await apiCtx.dispose();
115+
});
116+
});

0 commit comments

Comments
 (0)