History

Fix SSR crash from the admin nav and add org API key revoke

The admin nav hook read orgMembersAtom, which is wrapped in refreshOnWindowFocus — a browser-only atom whose signal touches window at read time. Evaluating it during SSR threw ReferenceError synchronously in render, above the routed outlet, so the whole authenticated document fell back to client rendering and the landing page was blank on first paint. Give the atom an explicit server value so its read never runs on the server; the role reads as unknown during SSR and resolves client-side, and the nav still fails closed. Org API keys could be minted and listed but never revoked. Add DELETE /account/org-api-keys/:apiKeyId with the same admin gate as the mint, resolving ownership against the org's own keys so a member's personal key or a sibling org's key cannot be destroyed through it. Self-host and cloudflare refuse it as they already refuse the mint.

Rhys Sullivan committed 3 months ago

Commit 445a4cd1 parent 8bca26215 files · +454 −8Browse files

Showing the changes to apps/cloud/src/auth (4 of 15 files). Show all

Unified · Split

4 files
  1. apps/cloud/src/auth/api-keys.ts +39 −0
  2. apps/cloud/src/auth/org-api-key-auth.node.test.ts +1 −0
  3. apps/cloud/src/auth/org-api-key-mint.node.test.ts +139 −3
  4. apps/cloud/src/auth/org-selector-auth.node.test.ts +1 −0
apps/cloud/src/auth/api-keys.ts+39−0
@@ -42,6 +42,16 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 41 unmodified lines
4242 readonly cause: unknown;
4343 }> {}
4444
45+/**
46+ * The requested key id is not an org-owned key of this organization — it does
47+ * not exist, belongs to a member rather than the org, or belongs to a different
48+ * org in the same WorkOS workspace. Distinct from `ApiKeyManagementError` (an
49+ * upstream failure) so callers can answer "not found" without a 500.
50+ */
51+export class OrgApiKeyNotFound extends Data.TaggedError("OrgApiKeyNotFound")<{
52+ readonly keyId: string;
53+}> {}
54+
4555 // WorkOS keys are owned by either a user or an organization. The user shape
4656 // carries the member id in `id` and the org in `organization_id`; the org shape
4757 // carries the ORG in `id` and no member at all. Both decode here — the
@@ -295,6 +305,16 @@ export class ApiKeyService extends Context.Service< · 247 unmodified lines
295305 readonly organizationId: string;
296306 readonly name: string;
297307 }) => Effect.Effect<CreatedApiKey, ApiKeyManagementError>;
308+ /**
309+ * Revoke an org-owned key. Ownership is resolved against the ORG's own key
310+ * listing — never the caller's personal keys — so a member's user key id
311+ * (or another org's key id, since the WorkOS workspace key is
312+ * workspace-wide) cannot be destroyed through this path.
313+ */
314+ readonly revokeOrgKey: (input: {
315+ readonly organizationId: string;
316+ readonly keyId: string;
317+ }) => Effect.Effect<void, ApiKeyManagementError | OrgApiKeyNotFound>;
298318 }
299319 >()("@executor-js/cloud/ApiKeyService") {
300320 static WorkOS = Layer.effect(this)(
@@ -340,6 +360,25 @@ export class ApiKeyService extends Context.Service< · 39 unmodified lines
340360 : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" }));
341361 }),
342362 ),
363+ // Ownership is checked against the ORG's decoded key listing before the
364+ // delete goes out. `deleteApiKey` is scope-blind — handed any key id it
365+ // would happily destroy a member's personal credential, or a key
366+ // belonging to a sibling org in the same workspace. The listing already
367+ // drops both of those (`orgListFromResponse` filters on owner type AND
368+ // organization), so filtering through it is the whole authorization.
369+ revokeOrgKey: ({ organizationId, keyId }) =>
370+ Effect.gen(function* () {
371+ const response = yield* workos
372+ .listOrgApiKeys(organizationId)
373+ .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
374+ const owned = orgListFromResponse(response, organizationId);
375+ if (!owned.some((key) => key.id === keyId)) {
376+ return yield* new OrgApiKeyNotFound({ keyId });
377+ }
378+ yield* workos
379+ .deleteApiKey(keyId)
380+ .pipe(Effect.mapError((cause) => new ApiKeyManagementError({ cause })));
381+ }),
343382 };
344383 }),
345384 );
apps/cloud/src/auth/org-api-key-auth.node.test.ts+1−0
@@ -38,6 +38,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 37 unmodified lines
3838 revokeUserKey: () => Effect.void,
3939 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
4040 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
41+ revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
4142 });
4243
4344 const stubWorkOS = Layer.succeed(
apps/cloud/src/auth/org-api-key-mint.node.test.ts+139−3
@@ -1,13 +1,13 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { Effect, Layer } from "effect";
33
4−import { ApiKeyService } from "./api-keys";
4+import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys";
55 import { ApiKeyManagementError } from "./errors";
66 import { WorkOSClient, type WorkOSClientService } from "./workos";
77
88 // ---------------------------------------------------------------------------
9−// Minting and listing ORGANIZATION-owned api keys — the privileged credential
10−// that resolves to the read-only platform view (`/admin/*`).
9+// Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged
10+// credential that resolves to the read-only platform view (`/admin/*`).
1111 //
1212 // The decode boundary is what these pin. WorkOS returns an org-owned key with
1313 // the ORG in `owner.id` and no member at all, which is a different shape from
@@ -178,3 +178,139 @@ describe("organization API keys", () => { · 164 unmodified lines
178178 }),
179179 );
180180 });
181+
182+// ---------------------------------------------------------------------------
183+// Revoking an org key. The mint is only half a credential lifecycle: an org key
184+// reads every user and connection in the tenant, so it has to be destroyable.
185+//
186+// Revoke cannot go through `revokeUserKey`/`revokeApiKey` — that path resolves
187+// ownership against the CALLER's personal keys, so an org key id is never in
188+// its owned set. It also cannot just call `deleteApiKey` directly: that route is
189+// scope-blind and would happily destroy a member's personal key, or a key
190+// belonging to a sibling org in the same WorkOS workspace. So ownership is
191+// resolved against the ORG's decoded listing first, and that filter IS the
192+// authorization. These pin that filter.
193+// ---------------------------------------------------------------------------
194+
195+describe("revoking an organization API key", () => {
196+ const listing = (...keys: ReadonlyArray<unknown>) => ({
197+ object: "list",
198+ data: keys,
199+ listMetadata: { before: null, after: null },
200+ });
201+
202+ it.effect("revokes a key the organization owns", () =>
203+ Effect.gen(function* () {
204+ const deleted: string[] = [];
205+ const apiKeys = yield* serviceWith({
206+ listOrgApiKeys: (organizationId: string) =>
207+ Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))),
208+ deleteApiKey: (id: string) =>
209+ Effect.sync(() => {
210+ deleted.push(id);
211+ }),
212+ });
213+
214+ yield* apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" });
215+
216+ expect(deleted, "the delete reaches WorkOS exactly once, for that key").toEqual([
217+ "key_org_1",
218+ ]);
219+ }),
220+ );
221+
222+ it.effect("refuses a USER-owned key id, without deleting anything", () =>
223+ Effect.gen(function* () {
224+ // The regression this guards: a member's personal credential must not be
225+ // destroyable through the org route just because an admin can call it.
226+ const deleted: string[] = [];
227+ const apiKeys = yield* serviceWith({
228+ listOrgApiKeys: () =>
229+ Effect.succeed(
230+ listing(orgKeyResponse(ORG, "key_org_1", "acme-backend"), {
231+ object: "api_key",
232+ id: "key_user",
233+ owner: { type: "user", id: "user_1", organization_id: ORG },
234+ name: "personal",
235+ obfuscatedValue: "sk_user…c3d4",
236+ lastUsedAt: null,
237+ createdAt: "2026-01-01T00:00:00.000Z",
238+ updatedAt: "2026-01-01T00:00:00.000Z",
239+ }),
240+ ),
241+ deleteApiKey: (id: string) =>
242+ Effect.sync(() => {
243+ deleted.push(id);
244+ }),
245+ });
246+
247+ const error = yield* Effect.flip(
248+ apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_user" }),
249+ );
250+
251+ expect(error).toBeInstanceOf(OrgApiKeyNotFound);
252+ expect(deleted, "no delete is issued for a key the org does not own").toEqual([]);
253+ }),
254+ );
255+
256+ it.effect("refuses a key belonging to ANOTHER organization", () =>
257+ Effect.gen(function* () {
258+ // The WorkOS workspace key is workspace-wide, so an admin of one org must
259+ // not be able to revoke a sibling org's credential by id.
260+ const deleted: string[] = [];
261+ const apiKeys = yield* serviceWith({
262+ listOrgApiKeys: () =>
263+ Effect.succeed(
264+ listing(
265+ orgKeyResponse(ORG, "key_mine", "mine"),
266+ orgKeyResponse("org_other", "key_theirs", "theirs"),
267+ ),
268+ ),
269+ deleteApiKey: (id: string) =>
270+ Effect.sync(() => {
271+ deleted.push(id);
272+ }),
273+ });
274+
275+ const error = yield* Effect.flip(
276+ apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_theirs" }),
277+ );
278+
279+ expect(error).toBeInstanceOf(OrgApiKeyNotFound);
280+ expect(deleted).toEqual([]);
281+ }),
282+ );
283+
284+ it.effect("refuses an unknown key id", () =>
285+ Effect.gen(function* () {
286+ const apiKeys = yield* serviceWith({
287+ listOrgApiKeys: () => Effect.succeed(listing()),
288+ deleteApiKey: () => Effect.die("nothing should be deleted"),
289+ });
290+
291+ const error = yield* Effect.flip(
292+ apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_nope" }),
293+ );
294+
295+ expect(error).toBeInstanceOf(OrgApiKeyNotFound);
296+ }),
297+ );
298+
299+ it.effect("surfaces an upstream delete failure as a management error", () =>
300+ Effect.gen(function* () {
301+ // A failed delete must not read as a successful revoke — the console
302+ // would tell an admin a live credential is gone.
303+ const apiKeys = yield* serviceWith({
304+ listOrgApiKeys: (organizationId: string) =>
305+ Effect.succeed(listing(orgKeyResponse(organizationId, "key_org_1", "acme-backend"))),
306+ deleteApiKey: () => Effect.fail("workos_unavailable"),
307+ });
308+
309+ const error = yield* Effect.flip(
310+ apiKeys.revokeOrgKey({ organizationId: ORG, keyId: "key_org_1" }),
311+ );
312+
313+ expect(error).toBeInstanceOf(ApiKeyManagementError);
314+ }),
315+ );
316+});
apps/cloud/src/auth/org-selector-auth.node.test.ts+1−0
@@ -31,6 +31,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 30 unmodified lines
3131 revokeUserKey: () => Effect.void,
3232 listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"),
3333 createOrgKey: () => Effect.die("auth resolution test does not create org API keys"),
34+ revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"),
3435 });
3536
3637 const stubWorkOS = Layer.succeed(