From c1429c317bd3ee95e99246bc49dfeef6601b2724 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Sat, 13 Jun 2026 20:25:12 -0700 Subject: [PATCH] fix(den-api): revoke credentials on role permission changes --- ...organization-role-credential-revocation.ts | 67 ++++++++++++ ee/apps/den-api/src/routes/org/roles.ts | 11 +- ...ization-role-credential-revocation.test.ts | 101 ++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 ee/apps/den-api/src/organization-role-credential-revocation.ts create mode 100644 ee/apps/den-api/test/organization-role-credential-revocation.test.ts diff --git a/ee/apps/den-api/src/organization-role-credential-revocation.ts b/ee/apps/den-api/src/organization-role-credential-revocation.ts new file mode 100644 index 000000000..e8f44272a --- /dev/null +++ b/ee/apps/den-api/src/organization-role-credential-revocation.ts @@ -0,0 +1,67 @@ +import { and, eq, isNull } from "@openwork-ee/den-db/drizzle" +import { MemberTable } from "@openwork-ee/den-db/schema" +import { revokeOrganizationApiKeysForMember } from "./api-keys.js" +import { revokeMembershipSessionCredentials } from "./credential-revocation.js" +import { db } from "./db.js" + +type OrganizationId = typeof MemberTable.$inferSelect.organizationId + +export type OrganizationRoleCredentialRevocationCounts = { + members: number + apiKeys: number + sessions: number + oauthAccessTokens: number + oauthRefreshTokens: number +} + +function splitRoleValue(value: string) { + return value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) +} + +export async function revokeCredentialsForOrganizationRoleMembers(input: { + organizationId: OrganizationId + role: string +}): Promise { + const members = await db + .select({ + id: MemberTable.id, + role: MemberTable.role, + userId: MemberTable.userId, + }) + .from(MemberTable) + .where(and(eq(MemberTable.organizationId, input.organizationId), isNull(MemberTable.removedAt))) + + const counts: OrganizationRoleCredentialRevocationCounts = { + members: 0, + apiKeys: 0, + sessions: 0, + oauthAccessTokens: 0, + oauthRefreshTokens: 0, + } + + for (const member of members) { + if (!splitRoleValue(member.role).includes(input.role)) { + continue + } + + counts.members += 1 + counts.apiKeys += await revokeOrganizationApiKeysForMember({ + organizationId: input.organizationId, + orgMembershipId: member.id, + userId: member.userId, + }) + + const credentials = await revokeMembershipSessionCredentials({ + organizationId: input.organizationId, + userId: member.userId, + }) + counts.sessions += credentials.sessions + counts.oauthAccessTokens += credentials.oauthAccessTokens + counts.oauthRefreshTokens += credentials.oauthRefreshTokens + } + + return counts +} diff --git a/ee/apps/den-api/src/routes/org/roles.ts b/ee/apps/den-api/src/routes/org/roles.ts index 163bed12c..e12d2fc67 100644 --- a/ee/apps/den-api/src/routes/org/roles.ts +++ b/ee/apps/den-api/src/routes/org/roles.ts @@ -9,6 +9,7 @@ import { db } from "../../db.js" import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js" import { emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, successSchema, unauthorizedSchema } from "../../openapi.js" import { validateAssignableOrganizationPermissionRecord } from "../../organization-access.js" +import { revokeCredentialsForOrganizationRoleMembers } from "../../organization-role-credential-revocation.js" import { serializePermissionRecord } from "../../orgs.js" import type { OrgRouteVariables } from "./shared.js" import { createRoleId, ensureOwner, idParamSchema, normalizeRoleName, replaceRoleValue, splitRoles } from "./shared.js" @@ -175,6 +176,7 @@ export function registerOrgRoleRoutes { + mock.module("../src/db.js", () => ({ + db: { + select: () => ({ + from: (table: unknown) => ({ + where: () => { + selectCalls += 1 + return Promise.resolve(table === MemberTable ? members : []) + }, + }), + }), + }, + })) + + mock.module("../src/api-keys.js", () => ({ + revokeOrganizationApiKeysForMember: (input: unknown) => { + apiKeyRevocations.push(input) + return Promise.resolve(1) + }, + })) + + mock.module("../src/credential-revocation.js", () => ({ + revokeMembershipSessionCredentials: (input: unknown) => { + credentialRevocations.push(input) + return Promise.resolve({ + sessions: input && typeof input === "object" && "userId" in input && input.userId ? 2 : 0, + oauthAccessTokens: input && typeof input === "object" && "userId" in input && input.userId ? 1 : 0, + oauthRefreshTokens: input && typeof input === "object" && "userId" in input && input.userId ? 1 : 0, + }) + }, + })) + + roleCredentialRevocationModule = await import("../src/organization-role-credential-revocation.js") +}) + +test("role credential revocation touches active members using the changed role", async () => { + resetCalls() + + const counts = await roleCredentialRevocationModule.revokeCredentialsForOrganizationRoleMembers({ + organizationId: "org_123", + role: "security-admin", + }) + + expect(counts).toEqual({ + members: 2, + apiKeys: 2, + sessions: 2, + oauthAccessTokens: 1, + oauthRefreshTokens: 1, + }) + expect(selectCalls).toBe(1) + expect(apiKeyRevocations).toEqual([ + { organizationId: "org_123", orgMembershipId: "member_one", userId: "user_one" }, + { organizationId: "org_123", orgMembershipId: "member_two", userId: null }, + ]) + expect(credentialRevocations).toEqual([ + { organizationId: "org_123", userId: "user_one" }, + { organizationId: "org_123", userId: null }, + ]) +}) + +test("role credential revocation skips members without the changed role", async () => { + resetCalls() + + const counts = await roleCredentialRevocationModule.revokeCredentialsForOrganizationRoleMembers({ + organizationId: "org_123", + role: "billing-admin", + }) + + expect(counts).toEqual({ + members: 0, + apiKeys: 0, + sessions: 0, + oauthAccessTokens: 0, + oauthRefreshTokens: 0, + }) + expect(selectCalls).toBe(1) + expect(apiKeyRevocations).toEqual([]) + expect(credentialRevocations).toEqual([]) +})