| @@ -1,13 +1,13 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Layer } from "effect"; | 2 | import { Effect, Layer } from "effect"; |
| 3 | | 3 | |
| 4 | import { ApiKeyService } from "./api-keys"; | 4 | import { ApiKeyService, OrgApiKeyNotFound } from "./api-keys"; |
| 5 | import { ApiKeyManagementError } from "./errors"; | 5 | import { ApiKeyManagementError } from "./errors"; |
| 6 | import { WorkOSClient, type WorkOSClientService } from "./workos"; | 6 | import { WorkOSClient, type WorkOSClientService } from "./workos"; |
| 7 | | 7 | |
| 8 | // --------------------------------------------------------------------------- | 8 | // --------------------------------------------------------------------------- |
| 9 | // Minting and listing ORGANIZATION-owned api keys — the privileged credential | 9 | // Minting, listing, and revoking ORGANIZATION-owned api keys — the privileged |
| 10 | // that resolves to the read-only platform view (`/admin/*`). | 10 | // credential that resolves to the read-only platform view (`/admin/*`). |
| 11 | // | 11 | // |
| 12 | // The decode boundary is what these pin. WorkOS returns an org-owned key with | 12 | // The decode boundary is what these pin. WorkOS returns an org-owned key with |
| 13 | // the ORG in `owner.id` and no member at all, which is a different shape from | 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 | }); |