Skip to content

Commit 042ceb6

Browse files
kjgbotkjgbotagent-relayclaude
authored
migration(rs256): phase 119 — Implement /v1/api-keys + x-api-key middleware (#20)
* migration(rs256): phase 119 — Implement /v1/api-keys + x-api-key middleware Generated by workflows/119-*.ts via scripts/run-rs256-migration.sh. Spec: specs/api-keys-and-rs256-migration.md (phase 119). Co-Authored-By: agent-relay <agent@agent-relay.com> * phase 119: fix P0 x-api-key middleware coverage + scope escalation - Mount apiKeyAuth() on /v1/identities/* (wildcard) and /v1/tokens/* so PATCH/DELETE/suspend/retire/reactivate accept x-api-key. - Global auth gate admits x-api-key as an alternate credential when the middleware has authenticated the request. - Enforce scopes ⊆ caller.scopes on POST /v1/api-keys to prevent privilege escalation via caller-supplied scope arrays. - Regression tests for every identity sub-path via x-api-key, plus scope-subset enforcement and legitimate-subset cases. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * phase 119: tighten global gate — remove raw x-api-key admission The previous gate admitted any request carrying a non-empty x-api-key header, relying on per-route handlers to validate. That's a footgun: a future route added without mounting apiKeyAuth() would silently accept unvalidated keys. apiKeyAuth() already rewrites Authorization into a synthesized Bearer on valid-key success, so every path that accepts x-api-key already reaches the gate with Authorization set. The gate now only checks for Authorization — no raw-x-api-key escape hatch. Regression test: request with only x-api-key to a path that doesn't mount apiKeyAuth() must return 401 missing_authorization. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: kjgbot <kjgbot@agentrelay.dev> Co-authored-by: agent-relay <agent@agent-relay.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0438cd4 commit 042ceb6

16 files changed

Lines changed: 2278 additions & 37 deletions

.rs256-migration-state.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,9 @@
22
"118": {
33
"status": "passed",
44
"ts": "2026-04-22T19:10:56Z"
5+
},
6+
"119": {
7+
"status": "passed",
8+
"ts": "2026-04-22T19:34:50Z"
59
}
610
}

.trajectories/completed/2026-04/traj_dmmcvsc1kghh.json

Lines changed: 382 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Trajectory: 119-api-keys-phase1-workflow
2+
3+
> **Status:** ✅ Completed
4+
> **Task:** abcd27407ffe3de58d0b1498
5+
> **Confidence:** 87%
6+
> **Started:** April 22, 2026 at 09:11 PM
7+
> **Completed:** April 22, 2026 at 09:33 PM
8+
9+
---
10+
11+
## Summary
12+
13+
Self-reviewed the API-key auth changes, fixed cross-org API key minting in POST /v1/api-keys, and verified the focused API-key/identity auth tests pass.
14+
15+
**Approach:** Standard approach
16+
17+
---
18+
19+
## Key Decisions
20+
21+
### Treat caller-supplied cross-org API key creation as a P0 and lock key creation to the caller's org
22+
- **Chose:** Treat caller-supplied cross-org API key creation as a P0 and lock key creation to the caller's org
23+
- **Reasoning:** An org-scoped bearer token can currently mint an API key for a different org, which creates cross-tenant impersonation via synthesized API-key claims.
24+
25+
---
26+
27+
## Chapters
28+
29+
### 1. Planning
30+
*Agent: orchestrator*
31+
32+
### 2. Execution: read-migration-spec, read-openapi, read-contract-test, read-storage-interfaces, read-existing-auth-lib, read-cloud-storage, read-cloud-migrations
33+
*Agent: orchestrator*
34+
35+
### 3. Convergence: read-migration-spec + read-openapi + read-contract-test + read-storage-interfaces + read-existing-auth-lib + read-cloud-storage + read-cloud-migrations
36+
*Agent: orchestrator*
37+
38+
- read-migration-spec + read-openapi + read-contract-test + read-storage-interfaces + read-existing-auth-lib + read-cloud-storage + read-cloud-migrations resolved. 7/7 steps completed. All steps completed on first attempt. Unblocking: write-relayauth-tests, implement-cloud-adapter.
39+
40+
### 4. Execution: write-relayauth-tests
41+
*Agent: relayauth-impl*
42+
43+
### 5. Execution: implement-relayauth, implement-cloud-adapter
44+
*Agent: orchestrator*
45+
46+
### 6. Execution: implement-relayauth
47+
*Agent: relayauth-impl*
48+
49+
### 7. Execution: implement-cloud-adapter
50+
*Agent: cloud-impl*
51+
52+
### 8. Convergence: implement-relayauth + implement-cloud-adapter
53+
*Agent: orchestrator*
54+
55+
- implement-relayauth + implement-cloud-adapter resolved. 2/2 steps completed. All steps completed on first attempt. Unblocking: verify-impl-files.
56+
57+
### 9. Execution: wire-bearer-or-apikey
58+
*Agent: relayauth-impl*
59+
60+
### 10. Execution: self-review
61+
*Agent: relayauth-impl*
62+
63+
- Treat caller-supplied cross-org API key creation as a P0 and lock key creation to the caller's org: Treat caller-supplied cross-org API key creation as a P0 and lock key creation to the caller's org
64+
- Completed self-review pass on the API-key path, fixed the cross-org key minting blocker, and am validating the remaining invariants against tests and schema.

.trajectories/index.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"version": 1,
3-
"lastUpdated": "2026-04-22T19:10:55.642Z",
3+
"lastUpdated": "2026-04-22T19:34:48.598Z",
44
"trajectories": {
55
"traj_a11zh8l3gbp5": {
66
"title": "Work on the relay: combine relayauth + relayfile for agent sandboxing",
@@ -917,6 +917,12 @@
917917
"status": "active",
918918
"startedAt": "2026-04-22T18:51:39.263Z",
919919
"path": "/Users/khaliqgant/Projects/AgentWorkforce/relayauth/.trajectories/active/traj_x2ejukwirx73.json"
920+
},
921+
"traj_dmmcvsc1kghh": {
922+
"title": "119-api-keys-phase1-workflow",
923+
"status": "active",
924+
"startedAt": "2026-04-22T19:11:05.487Z",
925+
"path": "/Users/khaliqgant/Projects/AgentWorkforce/relayauth/.trajectories/active/traj_dmmcvsc1kghh.json"
920926
}
921927
}
922928
}
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import type { AgentIdentity } from "@relayauth/types";
4+
import {
5+
assertJsonResponse,
6+
createTestApp,
7+
createTestRequest,
8+
generateTestToken,
9+
} from "./test-helpers.js";
10+
11+
type ApiKeyRecord = {
12+
id: string;
13+
orgId?: string;
14+
prefix: string;
15+
name: string;
16+
scopes: string[];
17+
createdAt?: string;
18+
revoked?: boolean;
19+
revokedAt?: string;
20+
};
21+
22+
type ApiKeyCreateResponse = {
23+
apiKey: ApiKeyRecord;
24+
key: string;
25+
};
26+
27+
type PaginatedApiKeyResponse = {
28+
data: ApiKeyRecord[];
29+
pagination?: {
30+
cursor?: string | null;
31+
hasMore: boolean;
32+
limit?: number;
33+
};
34+
};
35+
36+
function createAdminAuthorizationHeader(): HeadersInit {
37+
return {
38+
Authorization: `Bearer ${generateTestToken({
39+
sub: "agent_admin_api_keys",
40+
org: "org_test",
41+
wks: "ws_admin",
42+
sponsorId: "user_admin_api_keys",
43+
sponsorChain: ["user_admin_api_keys", "agent_admin_api_keys"],
44+
scopes: ["*"],
45+
})}`,
46+
};
47+
}
48+
49+
async function createApiKey(
50+
app: ReturnType<typeof createTestApp>,
51+
body: {
52+
name: string;
53+
scopes: string[];
54+
orgId?: string;
55+
},
56+
): Promise<ApiKeyCreateResponse> {
57+
const response = await app.request(
58+
createTestRequest("POST", "/v1/api-keys", body, createAdminAuthorizationHeader()),
59+
undefined,
60+
app.bindings,
61+
);
62+
63+
assert.equal(response.status, 201, "POST /v1/api-keys should create an API key");
64+
return assertJsonResponse<ApiKeyCreateResponse>(response, 201);
65+
}
66+
67+
async function createIdentityWithApiKey(
68+
app: ReturnType<typeof createTestApp>,
69+
apiKey: string,
70+
body: Record<string, unknown>,
71+
): Promise<Response> {
72+
return app.request(
73+
createTestRequest("POST", "/v1/identities", body, {
74+
"x-api-key": apiKey,
75+
}),
76+
undefined,
77+
app.bindings,
78+
);
79+
}
80+
81+
test("POST /v1/api-keys returns 201 with { apiKey, key } and a rak_ plaintext key", async () => {
82+
const app = createTestApp();
83+
84+
const body = await createApiKey(app, {
85+
name: "sage-specialist-caller",
86+
scopes: ["relayauth:identity:manage:*", "relayauth:token:create:*"],
87+
});
88+
89+
assert.equal(body.apiKey.name, "sage-specialist-caller");
90+
assert.deepEqual(body.apiKey.scopes, ["relayauth:identity:manage:*", "relayauth:token:create:*"]);
91+
assert.match(body.apiKey.id, /^ak_[A-Za-z0-9_-]+$/);
92+
assert.match(body.apiKey.prefix, /^rak_[A-Za-z0-9_-]{4,}/);
93+
assert.match(body.key, /^rak_[A-Za-z0-9_-]+$/);
94+
assert.ok(body.key.startsWith(body.apiKey.prefix), "returned plaintext should share the stored prefix");
95+
});
96+
97+
test("POST /v1/api-keys rejects cross-org creation requests", async () => {
98+
const app = createTestApp();
99+
100+
const response = await app.request(
101+
createTestRequest(
102+
"POST",
103+
"/v1/api-keys",
104+
{
105+
name: "cross-org-should-fail",
106+
orgId: "org_other",
107+
scopes: ["relayauth:identity:manage:*"],
108+
},
109+
createAdminAuthorizationHeader(),
110+
),
111+
undefined,
112+
app.bindings,
113+
);
114+
115+
const body = await assertJsonResponse<{ error: string; code?: string }>(response, 403);
116+
assert.equal(body.error, "org_mismatch");
117+
assert.equal(body.code, "org_mismatch");
118+
});
119+
120+
test("an API key returned from POST /v1/api-keys authenticates POST /v1/identities via x-api-key", async () => {
121+
const app = createTestApp();
122+
const created = await createApiKey(app, {
123+
name: "identity-bootstrap",
124+
scopes: ["relayauth:identity:manage:*"],
125+
});
126+
127+
const response = await createIdentityWithApiKey(app, created.key, {
128+
name: "service-created-via-api-key",
129+
sponsorId: "svc_sponsor_1",
130+
type: "service",
131+
metadata: {
132+
provisioner: "api-key-test",
133+
},
134+
});
135+
136+
const body = await assertJsonResponse<AgentIdentity>(response, 201);
137+
138+
assert.equal(body.name, "service-created-via-api-key");
139+
assert.equal(body.type, "service");
140+
assert.equal(body.orgId, "org_test");
141+
});
142+
143+
test("a revoked API key returns 401 on subsequent POST /v1/identities requests", async () => {
144+
const app = createTestApp();
145+
const created = await createApiKey(app, {
146+
name: "revoked-key",
147+
scopes: ["relayauth:identity:manage:*"],
148+
});
149+
150+
const revokeResponse = await app.request(
151+
createTestRequest(
152+
"POST",
153+
`/v1/api-keys/${created.apiKey.id}/revoke`,
154+
{},
155+
createAdminAuthorizationHeader(),
156+
),
157+
undefined,
158+
app.bindings,
159+
);
160+
const revoked = await assertJsonResponse<ApiKeyRecord>(revokeResponse, 200);
161+
assert.equal(revoked.id, created.apiKey.id);
162+
163+
const response = await createIdentityWithApiKey(app, created.key, {
164+
name: "should-not-create-after-revoke",
165+
sponsorId: "svc_sponsor_revoked",
166+
});
167+
168+
const body = await assertJsonResponse<{ error: string; code?: string }>(response, 401);
169+
assert.match(body.error, /api key|revoked|invalid/i);
170+
assert.notEqual(body.code, "missing_authorization");
171+
});
172+
173+
function createNarrowCallerAuthHeader(scopes: string[]): HeadersInit {
174+
return {
175+
Authorization: `Bearer ${generateTestToken({
176+
sub: "agent_narrow_caller",
177+
org: "org_test",
178+
wks: "ws_narrow",
179+
sponsorId: "user_narrow_caller",
180+
sponsorChain: ["user_narrow_caller", "agent_narrow_caller"],
181+
scopes,
182+
})}`,
183+
};
184+
}
185+
186+
test("POST /v1/api-keys rejects scope escalation: manage-only caller cannot mint scopes=['*']", async () => {
187+
const app = createTestApp();
188+
189+
const response = await app.request(
190+
createTestRequest(
191+
"POST",
192+
"/v1/api-keys",
193+
{
194+
name: "escalation-attempt-star",
195+
scopes: ["*"],
196+
},
197+
createNarrowCallerAuthHeader(["relayauth:api-key:manage:*"]),
198+
),
199+
undefined,
200+
app.bindings,
201+
);
202+
203+
const body = await assertJsonResponse<{ error: string; code?: string }>(response, 403);
204+
assert.equal(body.code, "scope_escalation");
205+
});
206+
207+
test("POST /v1/api-keys rejects partial scope escalation: narrow caller cannot widen via wildcard", async () => {
208+
const app = createTestApp();
209+
210+
// Caller has read access to a SPECIFIC identity path — cannot mint a wildcard read.
211+
const response = await app.request(
212+
createTestRequest(
213+
"POST",
214+
"/v1/api-keys",
215+
{
216+
name: "escalation-attempt-partial",
217+
scopes: ["relayauth:identity:read:*"],
218+
},
219+
createNarrowCallerAuthHeader([
220+
"relayauth:api-key:manage:*",
221+
"relayauth:identity:read:specific-id",
222+
]),
223+
),
224+
undefined,
225+
app.bindings,
226+
);
227+
228+
const body = await assertJsonResponse<{ error: string; code?: string }>(response, 403);
229+
assert.equal(body.code, "scope_escalation");
230+
});
231+
232+
test("POST /v1/api-keys allows legitimate subset: wildcard-grant caller CAN mint a narrower scope", async () => {
233+
const app = createTestApp();
234+
235+
const response = await app.request(
236+
createTestRequest(
237+
"POST",
238+
"/v1/api-keys",
239+
{
240+
name: "legit-subset",
241+
scopes: ["relayauth:identity:read:specific-id"],
242+
},
243+
createNarrowCallerAuthHeader([
244+
"relayauth:api-key:manage:*",
245+
"relayauth:identity:read:*",
246+
]),
247+
),
248+
undefined,
249+
app.bindings,
250+
);
251+
252+
const body = await assertJsonResponse<ApiKeyCreateResponse>(response, 201);
253+
assert.deepEqual(body.apiKey.scopes, ["relayauth:identity:read:specific-id"]);
254+
});
255+
256+
test("GET /v1/api-keys never returns the plaintext key and only exposes the prefix", async () => {
257+
const app = createTestApp();
258+
const created = await createApiKey(app, {
259+
name: "list-visible-prefix-only",
260+
scopes: ["relayauth:identity:manage:*"],
261+
});
262+
263+
const response = await app.request(
264+
createTestRequest("GET", "/v1/api-keys", undefined, createAdminAuthorizationHeader()),
265+
undefined,
266+
app.bindings,
267+
);
268+
const body = await assertJsonResponse<PaginatedApiKeyResponse>(response, 200);
269+
270+
assert.ok(Array.isArray(body.data));
271+
const listed = body.data.find((apiKey) => apiKey.id === created.apiKey.id);
272+
assert.ok(listed, "expected created API key to appear in the list response");
273+
assert.equal(listed?.prefix, created.apiKey.prefix);
274+
assert.equal("key" in (listed ?? {}), false, "list response must not expose plaintext keys");
275+
assert.doesNotMatch(JSON.stringify(body), new RegExp(created.key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
276+
});

0 commit comments

Comments
 (0)