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.
Showing the changes to apps/cloud/src (8 of 15 files). Show all
Unified · Split
| @@ -0,0 +1,192 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, Layer } from "effect"; | |
| 3 | + | |
| 4 | +import { AccountProvider } from "@executor-js/api/server"; | |
| 5 | +import { AccountError, AccountForbidden } from "@executor-js/api"; | |
| 6 | + | |
| 7 | +import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; | |
| 8 | +import { UserStoreService } from "../auth/context"; | |
| 9 | +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; | |
| 10 | +import { AutumnService } from "../extensions/billing/service"; | |
| 11 | +import { AccountCaller, workosAccountProvider } from "./workos-account-service"; | |
| 12 | + | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | +// `DELETE /account/org-api-keys/:apiKeyId` at the PROVIDER boundary. | |
| 15 | +// | |
| 16 | +// An org key reads every user and connection in the tenant through `/admin/*`, | |
| 17 | +// so it is the most privileged credential the product issues. Two properties | |
| 18 | +// have to hold together, and only this seam sees both: | |
| 19 | +// | |
| 20 | +// 1. Revoke is gated on org ADMIN membership — the same gate as the mint. A | |
| 21 | +// plain member calling it gets 403 and no delete goes out. | |
| 22 | +// 2. Revoke resolves ownership against the ORG's keys. `revokeApiKey` (the | |
| 23 | +// personal path) checks the caller's OWN key list, so it can never revoke | |
| 24 | +// an org key; conversely this path must never destroy a member's personal | |
| 25 | +// key. The scope-blind `deleteApiKey` would do exactly that if handed a | |
| 26 | +// user key id, so a wrong id has to fail cleanly instead. | |
| 27 | +// | |
| 28 | +// Ownership filtering itself is pinned per-shape in | |
| 29 | +// `auth/org-api-key-mint.node.test.ts`; here it is asserted end-to-end through | |
| 30 | +// the gate, on the provider the HTTP handler actually calls. | |
| 31 | +// --------------------------------------------------------------------------- | |
| 32 | + | |
| 33 | +const ORG = "org_123"; | |
| 34 | +const ADMIN = "user_admin"; | |
| 35 | +const MEMBER = "user_member"; | |
| 36 | +const ORG_KEY = "key_org_1"; | |
| 37 | +const USER_KEY = "key_user_1"; | |
| 38 | +const createdAt = new Date("2026-01-01T00:00:00.000Z"); | |
| 39 | + | |
| 40 | +const session = (accountId: string) => ({ | |
| 41 | + accountId, | |
| 42 | + email: `${accountId}@example.test`, | |
| 43 | + name: null, | |
| 44 | + avatarUrl: null, | |
| 45 | + organizationId: ORG, | |
| 46 | + sealedSession: "sealed", | |
| 47 | + refreshedSession: null, | |
| 48 | +}); | |
| 49 | + | |
| 50 | +/** Membership roles: only ADMIN carries the `admin` role slug. */ | |
| 51 | +const stubWorkOS = Layer.succeed( | |
| 52 | + WorkOSClient, | |
| 53 | + new Proxy({} as WorkOSClientService, { | |
| 54 | + get: (_target, prop) => { | |
| 55 | + if (prop === "listUserMemberships") { | |
| 56 | + return (userId: string) => | |
| 57 | + Effect.succeed({ | |
| 58 | + data: [{ userId, organizationId: ORG, status: "active" }], | |
| 59 | + }); | |
| 60 | + } | |
| 61 | + if (prop === "getUserOrgMembership") { | |
| 62 | + return (organizationId: string, userId: string) => | |
| 63 | + Effect.succeed( | |
| 64 | + organizationId === ORG | |
| 65 | + ? { | |
| 66 | + id: `om_${userId}`, | |
| 67 | + userId, | |
| 68 | + organizationId, | |
| 69 | + role: { slug: userId === ADMIN ? "admin" : "member" }, | |
| 70 | + } | |
| 71 | + : null, | |
| 72 | + ); | |
| 73 | + } | |
| 74 | + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); | |
| 75 | + }, | |
| 76 | + }), | |
| 77 | +); | |
| 78 | + | |
| 79 | +const stubUsers = Layer.succeed(UserStoreService)({ | |
| 80 | + use: (fn) => | |
| 81 | + Effect.promise(() => | |
| 82 | + fn({ | |
| 83 | + ensureAccount: async (id: string) => ({ id, createdAt }), | |
| 84 | + getAccount: async (id: string) => ({ id, createdAt }), | |
| 85 | + upsertOrganization: async (org: { id: string; name: string }) => ({ | |
| 86 | + ...org, | |
| 87 | + slug: org.id, | |
| 88 | + createdAt, | |
| 89 | + }), | |
| 90 | + getOrganization: async (id: string) => ({ | |
| 91 | + id, | |
| 92 | + name: `Org ${id}`, | |
| 93 | + slug: id, | |
| 94 | + createdAt, | |
| 95 | + }), | |
| 96 | + getOrganizationBySlug: async (slug: string) => ({ | |
| 97 | + id: slug, | |
| 98 | + name: `Org ${slug}`, | |
| 99 | + slug, | |
| 100 | + createdAt, | |
| 101 | + }), | |
| 102 | + deleteOrganizationCascade: async () => {}, | |
| 103 | + }), | |
| 104 | + ), | |
| 105 | +}); | |
| 106 | + | |
| 107 | +const stubAutumn = Layer.succeed(AutumnService)({ | |
| 108 | + use: () => Effect.die("revoke does not touch billing"), | |
| 109 | + checkExecutionBalance: () => Effect.die("revoke does not touch billing"), | |
| 110 | + trackExecution: () => Effect.void, | |
| 111 | +}); | |
| 112 | + | |
| 113 | +/** | |
| 114 | + * The provider under test, over an `ApiKeyService` whose org listing holds a | |
| 115 | + * single org-owned key. Revoked ids land in `revoked` so "no delete happened" | |
| 116 | + * is assertable, not merely inferred from the error. | |
| 117 | + */ | |
| 118 | +const providerWith = (accountId: string) => { | |
| 119 | + const revoked: string[] = []; | |
| 120 | + const stubApiKeys = Layer.succeed(ApiKeyService)({ | |
| 121 | + validate: () => Effect.die("revoke test does not validate keys"), | |
| 122 | + listUserKeys: () => Effect.succeed([]), | |
| 123 | + createUserKey: () => Effect.die("revoke test does not create user keys"), | |
| 124 | + revokeUserKey: () => Effect.die("the ORG path must not call the personal revoke"), | |
| 125 | + listOrgKeys: () => Effect.die("revoke test does not list org keys"), | |
| 126 | + createOrgKey: () => Effect.die("revoke test does not create org keys"), | |
| 127 | + revokeOrgKey: ({ keyId }) => | |
| 128 | + keyId === ORG_KEY | |
| 129 | + ? Effect.sync(() => { | |
| 130 | + revoked.push(keyId); | |
| 131 | + }) | |
| 132 | + : Effect.fail(new OrgApiKeyNotFound({ keyId })), | |
| 133 | + }); | |
| 134 | + | |
| 135 | + const provider = AccountProvider.asEffect().pipe( | |
| 136 | + Effect.provide( | |
| 137 | + workosAccountProvider.pipe( | |
| 138 | + Layer.provide( | |
| 139 | + Layer.mergeAll( | |
| 140 | + stubWorkOS, | |
| 141 | + stubUsers, | |
| 142 | + stubApiKeys, | |
| 143 | + stubAutumn, | |
| 144 | + Layer.succeed(AccountCaller)({ session: session(accountId) }), | |
| 145 | + ), | |
| 146 | + ), | |
| 147 | + ), | |
| 148 | + ), | |
| 149 | + ); | |
| 150 | + | |
| 151 | + return { provider, revoked }; | |
| 152 | +}; | |
| 153 | + | |
| 154 | +describe("revokeOrgApiKey · provider boundary", () => { | |
| 155 | + it.effect("an org admin revokes an org-owned key", () => | |
| 156 | + Effect.gen(function* () { | |
| 157 | + const { provider, revoked } = providerWith(ADMIN); | |
| 158 | + const account = yield* provider; | |
| 159 | + | |
| 160 | + const result = yield* account.revokeOrgApiKey({}, ORG_KEY); | |
| 161 | + | |
| 162 | + expect(result).toEqual({ success: true }); | |
| 163 | + expect(revoked, "the revoke reached the key service").toEqual([ORG_KEY]); | |
| 164 | + }), | |
| 165 | + ); | |
| 166 | + | |
| 167 | + it.effect("a plain member is forbidden, and nothing is revoked", () => | |
| 168 | + Effect.gen(function* () { | |
| 169 | + const { provider, revoked } = providerWith(MEMBER); | |
| 170 | + const account = yield* provider; | |
| 171 | + | |
| 172 | + const error = yield* Effect.flip(account.revokeOrgApiKey({}, ORG_KEY)); | |
| 173 | + | |
| 174 | + expect(error, "same admin gate as the mint").toBeInstanceOf(AccountForbidden); | |
| 175 | + expect(revoked, "the gate runs BEFORE the key service is touched").toEqual([]); | |
| 176 | + }), | |
| 177 | + ); | |
| 178 | + | |
| 179 | + it.effect("a user-scoped key id fails cleanly through this path", () => | |
| 180 | + Effect.gen(function* () { | |
| 181 | + // Not found — not a 500, and emphatically not a silent success that would | |
| 182 | + // report a member's live personal credential as destroyed. | |
| 183 | + const { provider, revoked } = providerWith(ADMIN); | |
| 184 | + const account = yield* provider; | |
| 185 | + | |
| 186 | + const error = yield* Effect.flip(account.revokeOrgApiKey({}, USER_KEY)); | |
| 187 | + | |
| 188 | + expect(error).toBeInstanceOf(AccountError); | |
| 189 | + expect(revoked).toEqual([]); | |
| 190 | + }), | |
| 191 | + ); | |
| 192 | +}); | |
| @@ -272,6 +272,26 @@ export const workosAccountProvider: Layer.Layer< · 271 unmodified lines | ||
| 272 | 272 | .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); |
| 273 | 273 | }), |
| 274 | 274 | |
| 275 | + // Same admin gate as the mint. Note this deliberately does NOT reuse | |
| 276 | + // `revokeApiKey`: that path resolves ownership against the caller's | |
| 277 | + // personal keys, so an org key id is never in its owned set. Ownership | |
| 278 | + // here is resolved against the ORG's keys inside `revokeOrgKey`, which is | |
| 279 | + // what keeps a user-scoped (or foreign-org) key id from being destroyed | |
| 280 | + // through the org route — it surfaces as a 404-shaped AccountError, not a | |
| 281 | + // silent success and not a 500. | |
| 282 | + revokeOrgApiKey: (headers, apiKeyId) => | |
| 283 | + Effect.gen(function* () { | |
| 284 | + const { session, org } = yield* requireOrganization(headers); | |
| 285 | + yield* requireAdmin(session.accountId, org.id); | |
| 286 | + yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe( | |
| 287 | + Effect.catchTag("ApiKeyManagementError", toAccountError), | |
| 288 | + Effect.catchTag("OrgApiKeyNotFound", () => | |
| 289 | + Effect.fail(new AccountError({ message: "Organization API key not found" })), | |
| 290 | + ), | |
| 291 | + ); | |
| 292 | + return { success: true }; | |
| 293 | + }), | |
| 294 | + | |
| 275 | 295 | listMembers: (headers) => |
| 276 | 296 | Effect.gen(function* () { |
| 277 | 297 | const { session, org } = yield* requireOrganization(headers); |
| @@ -25,6 +25,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 24 unmodified lines | ||
| 25 | 25 | revokeUserKey: () => Effect.void, |
| 26 | 26 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), |
| 27 | 27 | createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), |
| 28 | + revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | |
| 28 | 29 | }); |
| 29 | 30 | |
| 30 | 31 | const stubWorkOS = Layer.succeed( |
| @@ -42,6 +42,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 41 unmodified lines | ||
| 42 | 42 | revokeUserKey: () => Effect.void, |
| 43 | 43 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), |
| 44 | 44 | createOrgKey: () => Effect.die("auth resolution test does not create org API keys"), |
| 45 | + revokeOrgKey: () => Effect.die("auth resolution test does not revoke org API keys"), | |
| 45 | 46 | }); |
| 46 | 47 | |
| 47 | 48 | const stubWorkOS = Layer.succeed( |
| @@ -42,6 +42,16 @@ export class ApiKeyValidationError extends Data.TaggedError("ApiKeyValidationErr · 41 unmodified lines | ||
| 42 | 42 | readonly cause: unknown; |
| 43 | 43 | }> {} |
| 44 | 44 | |
| 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 | + | |
| 45 | 55 | // WorkOS keys are owned by either a user or an organization. The user shape |
| 46 | 56 | // carries the member id in `id` and the org in `organization_id`; the org shape |
| 47 | 57 | // 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 | ||
| 295 | 305 | readonly organizationId: string; |
| 296 | 306 | readonly name: string; |
| 297 | 307 | }) => 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>; | |
| 298 | 318 | } |
| 299 | 319 | >()("@executor-js/cloud/ApiKeyService") { |
| 300 | 320 | static WorkOS = Layer.effect(this)( |
| @@ -340,6 +360,25 @@ export class ApiKeyService extends Context.Service< · 39 unmodified lines | ||
| 340 | 360 | : Effect.fail(new ApiKeyManagementError({ cause: "invalid_create_response" })); |
| 341 | 361 | }), |
| 342 | 362 | ), |
| 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 | + }), | |
| 343 | 382 | }; |
| 344 | 383 | }), |
| 345 | 384 | ); |
| @@ -38,6 +38,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 37 unmodified lines | ||
| 38 | 38 | revokeUserKey: () => Effect.void, |
| 39 | 39 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), |
| 40 | 40 | 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"), | |
| 41 | 42 | }); |
| 42 | 43 | |
| 43 | 44 | const stubWorkOS = Layer.succeed( |
| @@ -1,13 +1,13 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { Effect, Layer } from "effect"; |
| 3 | 3 | |
| 4 | −import { ApiKeyService } from "./api-keys"; | |
| 4 | +import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys"; | |
| 5 | 5 | import { ApiKeyManagementError } from "./errors"; |
| 6 | 6 | import { WorkOSClient, type WorkOSClientService } from "./workos"; |
| 7 | 7 | |
| 8 | 8 | // --------------------------------------------------------------------------- |
| 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/*`). | |
| 11 | 11 | // |
| 12 | 12 | // The decode boundary is what these pin. WorkOS returns an org-owned key with |
| 13 | 13 | // 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 | ||
| 178 | 178 | }), |
| 179 | 179 | ); |
| 180 | 180 | }); |
| 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 | +}); | |
| @@ -31,6 +31,7 @@ const stubApiKeys = Layer.succeed(ApiKeyService)({ · 30 unmodified lines | ||
| 31 | 31 | revokeUserKey: () => Effect.void, |
| 32 | 32 | listOrgKeys: () => Effect.die("auth resolution test does not list org API keys"), |
| 33 | 33 | 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"), | |
| 34 | 35 | }); |
| 35 | 36 | |
| 36 | 37 | const stubWorkOS = Layer.succeed( |