| @@ -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 | +}); |