Skip to content

Commit bdda71c

Browse files
viktormarinhoclaude
andcommitted
test(e2e): an API key is capped at its allowlist regardless of owner role
Black-box HTTP regression: an admin-owned key scoped to AUTOMATION_LIST is denied MONITORING_STATS and API_KEY_CREATE (403); a wildcard key keeps full access; and API_KEY_CREATE without `permissions` is rejected (400) — there is no key without a scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 077d97b commit bdda71c

1 file changed

Lines changed: 127 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* E2E: an API key is authorized SOLELY by its own allowlist — never by the
3+
* owner's org role.
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 scoped to one tool is denied an out-of-scope tool (403), including
12+
* the exact escalation (API_KEY_CREATE), even though the owner is an admin;
13+
* - a key with an explicit wildcard still has full access (not denied);
14+
* - there is no "key without a scope": API_KEY_CREATE requires `permissions`.
15+
*/
16+
import { signUpViaApi } from "../fixtures/auth-api";
17+
import { expect, newApiContext, test } from "../fixtures/test";
18+
19+
test.describe("API key scope enforcement", () => {
20+
test("a key is capped at its allowlist regardless of the owner's role", async ({
21+
playwright,
22+
}) => {
23+
const ownerCtx = await newApiContext(playwright);
24+
const owner = await signUpViaApi(ownerCtx);
25+
const stamp = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
26+
27+
// The owner is admin/owner of their auto-created org. Mint a key scoped to a
28+
// single tool. (Scoped keys are created server-side via the tool — Better
29+
// Auth blocks `permissions` on cookie/client requests — and the owner's
30+
// admin role authorizes the cookie-session call here.)
31+
const createScoped = await ownerCtx.post(
32+
`/api/${owner.orgSlug}/tools/API_KEY_CREATE`,
33+
{
34+
data: {
35+
name: `scoped-${stamp}`,
36+
permissions: { self: ["AUTOMATION_LIST"] },
37+
},
38+
},
39+
);
40+
expect(
41+
createScoped.ok(),
42+
`API_KEY_CREATE(scoped): HTTP ${createScoped.status()}${await createScoped.text().catch(() => "")}`,
43+
).toBe(true);
44+
const scopedKey = ((await createScoped.json()) as { key?: string }).key;
45+
expect(scopedKey, "scoped key value returned").toBeTruthy();
46+
47+
// A key with an explicit wildcard is a full key.
48+
const createFull = await ownerCtx.post(
49+
`/api/${owner.orgSlug}/tools/API_KEY_CREATE`,
50+
{ data: { name: `full-${stamp}`, permissions: { "*": ["*"] } } },
51+
);
52+
expect(
53+
createFull.ok(),
54+
`API_KEY_CREATE(full): HTTP ${createFull.status()}`,
55+
).toBe(true);
56+
const fullKey = ((await createFull.json()) as { key?: string }).key;
57+
expect(fullKey, "full key value returned").toBeTruthy();
58+
59+
// There is no "key without a scope": permissions are required.
60+
const createNoScope = await ownerCtx.post(
61+
`/api/${owner.orgSlug}/tools/API_KEY_CREATE`,
62+
{ data: { name: `noscope-${stamp}` } },
63+
);
64+
expect(
65+
createNoScope.status(),
66+
`API_KEY_CREATE without permissions should be 400, got ${createNoScope.status()}`,
67+
).toBe(400);
68+
69+
// Bearer-auth context with no session cookie → auth is purely the API key.
70+
const apiCtx = await newApiContext(playwright);
71+
const auth = (key: string) => ({ Authorization: `Bearer ${key}` });
72+
73+
// In allowlist → allowed (the key authenticates and works).
74+
const inScope = await apiCtx.post(
75+
`/api/${owner.orgSlug}/tools/AUTOMATION_LIST`,
76+
{ headers: auth(scopedKey!), data: {} },
77+
);
78+
expect(
79+
inScope.ok(),
80+
`scoped AUTOMATION_LIST: HTTP ${inScope.status()}`,
81+
).toBe(true);
82+
83+
// Out of allowlist AND not a basic-usage tool → DENIED, even though the
84+
// owner is an admin. This is the fix.
85+
const outOfScope = await apiCtx.post(
86+
`/api/${owner.orgSlug}/tools/MONITORING_STATS`,
87+
{ headers: auth(scopedKey!), data: {} },
88+
);
89+
expect(
90+
outOfScope.status(),
91+
`scoped MONITORING_STATS should be 403, got ${outOfScope.status()}`,
92+
).toBe(403);
93+
const deniedBody = (await outOfScope.json()) as { error?: string };
94+
expect(deniedBody.error ?? "").toMatch(
95+
/access denied|permission|forbidden/i,
96+
);
97+
98+
// The exact escalation from the report: a scoped key must not mint keys.
99+
const escalate = await apiCtx.post(
100+
`/api/${owner.orgSlug}/tools/API_KEY_CREATE`,
101+
{
102+
headers: auth(scopedKey!),
103+
data: {
104+
name: `should-not-work-${stamp}`,
105+
permissions: { self: ["*"] },
106+
},
107+
},
108+
);
109+
expect(
110+
escalate.status(),
111+
`scoped API_KEY_CREATE should be 403, got ${escalate.status()}`,
112+
).toBe(403);
113+
114+
// A wildcard key keeps full access (not denied) → full keys aren't broken.
115+
const fullReach = await apiCtx.post(
116+
`/api/${owner.orgSlug}/tools/MONITORING_STATS`,
117+
{ headers: auth(fullKey!), data: {} },
118+
);
119+
expect(
120+
fullReach.status(),
121+
`wildcard key MONITORING_STATS must not be 403, got ${fullReach.status()}`,
122+
).not.toBe(403);
123+
124+
await ownerCtx.dispose();
125+
await apiCtx.dispose();
126+
});
127+
});

0 commit comments

Comments
 (0)