import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companyMemberships, companySecretBindings, companySecrets, connectionGrantDelegations, connectionGrantMembers, connectionGrants, instanceUserRoles, issues, principalPermissionGrants, toolAccessAuditEvents, toolConnections, userSecretDeclarations, } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; import { conflict } from "../errors.js"; import { assertAssignableAgent } from "./agent-assignability.js"; import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js"; import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js"; type MembershipRow = typeof companyMemberships.$inferSelect; type GrantInput = { permissionKey: PermissionKey; scope?: Record | null; }; type MemberArchiveInput = { reassignment?: { assigneeAgentId?: string | null; assigneeUserId?: string | null; } | null; }; export function accessService(db: Db) { const authorization = authorizationService(db); async function sweepMemberConnectionAccess( tx: Parameters[0]>[0], companyId: string, userId: string, now: Date, ) { const departingAudienceRows = await tx.select({ grantId: connectionGrantMembers.grantId, }).from(connectionGrantMembers).where(and( eq(connectionGrantMembers.companyId, companyId), eq(connectionGrantMembers.subjectType, "user"), eq(connectionGrantMembers.subjectId, userId), )); const departingAudienceGrantIds = [...new Set(departingAudienceRows.map((row) => row.grantId))]; const affectedAudienceRows = departingAudienceGrantIds.length === 0 ? [] : await tx.select({ grantId: connectionGrantMembers.grantId, subjectId: connectionGrantMembers.subjectId, }).from(connectionGrantMembers).where(and( eq(connectionGrantMembers.companyId, companyId), eq(connectionGrantMembers.subjectType, "user"), inArray(connectionGrantMembers.grantId, departingAudienceGrantIds), )); const activeOrganizationAudienceGrants = departingAudienceGrantIds.length === 0 ? [] : await tx.select({ id: connectionGrants.id, }).from(connectionGrants).where(and( eq(connectionGrants.companyId, companyId), eq(connectionGrants.kind, "organization"), eq(connectionGrants.status, "active"), inArray(connectionGrants.id, departingAudienceGrantIds), )); const activeOrganizationAudienceGrantIds = new Set(activeOrganizationAudienceGrants.map((grant) => grant.id)); const soleAudienceGrantIds = new Set(departingAudienceGrantIds.filter((grantId) => activeOrganizationAudienceGrantIds.has(grantId) && affectedAudienceRows.filter((row) => row.grantId === grantId).length === 1, )); const ownedGrants = await tx.select({ id: connectionGrants.id, connectionId: connectionGrants.connectionId, credentialSecretRefs: connectionGrants.credentialSecretRefs, }).from(connectionGrants).where(and( eq(connectionGrants.companyId, companyId), eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, userId), )); const grantIds = ownedGrants.map((grant) => grant.id); const ownedGrantIds = new Set(grantIds); const affectedConnectionIds = [...new Set(ownedGrants.map((grant) => grant.connectionId))]; const affectedConnections = new Set(affectedConnectionIds); // Membership removal revokes personal connection identities. It must not // erase unrelated user-scoped values used by agent or environment secret // declarations or bindings. Start with only secrets the departing user's // grants explicitly reference, then fail toward retention whenever another // consumer still names the secret or its user-secret definition. const referencedSecretIds = [...new Set(ownedGrants.flatMap((grant) => grant.credentialSecretRefs.map((ref) => ref.secretId), ))]; const ownedSecrets = referencedSecretIds.length === 0 ? [] : await tx.select({ id: companySecrets.id, userSecretDefinitionId: companySecrets.userSecretDefinitionId, }).from(companySecrets).where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, userId), inArray(companySecrets.id, referencedSecretIds), )); const ownedSecretIds = ownedSecrets.map((secret) => secret.id); const ownedSecretSet = new Set(ownedSecretIds); const retainedSecretIds = new Set(); const sharedConnectionSecretIds = new Set(); let grantMemberRefs: Array<{ grantId: string; subjectId: string }> = []; let existingMemberUserIds = new Set(); let grantRefs: Array<{ id: string; connectionId: string; kind: typeof connectionGrants.$inferSelect.kind; status: typeof connectionGrants.$inferSelect.status; credentialSecretRefs: typeof connectionGrants.$inferSelect.credentialSecretRefs; }> = []; let connectionRefs: Array<{ id: string; credentialRefs: typeof toolConnections.$inferSelect.credentialRefs; credentialSecretRefs: typeof toolConnections.$inferSelect.credentialSecretRefs; }> = []; if (ownedSecretIds.length > 0) { const definitionIds = ownedSecrets.flatMap((secret) => secret.userSecretDefinitionId ? [secret.userSecretDefinitionId] : [], ); const [ bindingRefs, declarationRefs, allGrantRefs, allConnectionRefs, allGrantMemberRefs, membershipRefs, ] = await Promise.all([ tx.select({ secretId: companySecretBindings.secretId, targetType: companySecretBindings.targetType, targetId: companySecretBindings.targetId, }).from(companySecretBindings).where(and( eq(companySecretBindings.companyId, companyId), inArray(companySecretBindings.secretId, ownedSecretIds), )), definitionIds.length === 0 ? Promise.resolve([]) : tx.select({ userSecretDefinitionId: userSecretDeclarations.userSecretDefinitionId, }).from(userSecretDeclarations).where(and( eq(userSecretDeclarations.companyId, companyId), inArray(userSecretDeclarations.userSecretDefinitionId, definitionIds), )), tx.select({ id: connectionGrants.id, connectionId: connectionGrants.connectionId, kind: connectionGrants.kind, status: connectionGrants.status, credentialSecretRefs: connectionGrants.credentialSecretRefs, }).from(connectionGrants).where(eq(connectionGrants.companyId, companyId)), tx.select({ id: toolConnections.id, credentialRefs: toolConnections.credentialRefs, credentialSecretRefs: toolConnections.credentialSecretRefs, }).from(toolConnections).where(eq(toolConnections.companyId, companyId)), tx.select({ grantId: connectionGrantMembers.grantId, subjectId: connectionGrantMembers.subjectId, }).from(connectionGrantMembers).where(and( eq(connectionGrantMembers.companyId, companyId), eq(connectionGrantMembers.subjectType, "user"), )), tx.select({ userId: companyMemberships.principalId }).from(companyMemberships).where(and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), ne(companyMemberships.principalId, userId), )), ]); grantRefs = allGrantRefs; connectionRefs = allConnectionRefs; grantMemberRefs = allGrantMemberRefs; existingMemberUserIds = new Set(membershipRefs.map((row) => row.userId)); for (const binding of bindingRefs) { if (binding.targetType !== "tool_connection" || !affectedConnections.has(binding.targetId)) { retainedSecretIds.add(binding.secretId); } } const declaredDefinitions = new Set(declarationRefs.map((row) => row.userSecretDefinitionId)); for (const secret of ownedSecrets) { if (secret.userSecretDefinitionId && declaredDefinitions.has(secret.userSecretDefinitionId)) { retainedSecretIds.add(secret.id); } } for (const grant of grantRefs) { if (ownedGrantIds.has(grant.id)) continue; const grantAudience = grantMemberRefs.filter((member) => member.grantId === grant.id); const remainingGrantAudience = grantAudience.filter((member) => member.subjectId !== userId); const hasSurvivingOrganizationAudience = grant.kind === "organization" && grant.status === "active" && ( soleAudienceGrantIds.has(grant.id) ? true : remainingGrantAudience.length === 0 ? existingMemberUserIds.size > 0 : remainingGrantAudience.some((member) => existingMemberUserIds.has(member.subjectId)) ); for (const ref of grant.credentialSecretRefs) { if (!ownedSecretSet.has(ref.secretId)) continue; if (!affectedConnections.has(grant.connectionId)) { retainedSecretIds.add(ref.secretId); } else if (grant.kind === "user" || hasSurvivingOrganizationAudience) { // A connection may temporarily carry separate user grants that // reference the same credential, or an organization grant may // still have another persisted audience member. A sole named // audience row stays persisted instead of being widened to company // scope; both resolvers require current active membership, so the // row remains dormant until company access is restored. Pending, // suspended, and archived memberships are intentionally included // because company access can reactivate each of them later. retainedSecretIds.add(ref.secretId); sharedConnectionSecretIds.add(ref.secretId); } } } for (const connection of connectionRefs) { if (affectedConnections.has(connection.id)) continue; for (const ref of [...connection.credentialRefs, ...connection.credentialSecretRefs]) { if (ownedSecretSet.has(ref.secretId)) retainedSecretIds.add(ref.secretId); } } } const secretIdsToDelete = ownedSecretIds.filter((secretId) => !retainedSecretIds.has(secretId)); const connectionSecretIdsToRemove = new Set( ownedSecretIds.filter((secretId) => !sharedConnectionSecretIds.has(secretId)), ); const removedDelegations = grantIds.length === 0 ? [] : await tx .delete(connectionGrantDelegations) .where(and( eq(connectionGrantDelegations.companyId, companyId), inArray(connectionGrantDelegations.grantId, grantIds), )) .returning(); if (grantIds.length > 0) { await tx.update(connectionGrants).set({ status: "revoked", isDefault: false, revokedAt: now, revokedByUserId: null, revokedByAgentId: null, updatedAt: now, }).where(and( eq(connectionGrants.companyId, companyId), inArray(connectionGrants.id, grantIds), )); } if (ownedSecretIds.length > 0) { for (const grant of grantRefs) { if (!ownedGrantIds.has(grant.id) && !affectedConnections.has(grant.connectionId)) continue; const refsToRemove = ownedGrantIds.has(grant.id) ? ownedSecretSet : connectionSecretIdsToRemove; const credentialSecretRefs = grant.credentialSecretRefs.filter( (ref) => !refsToRemove.has(ref.secretId), ); if (credentialSecretRefs.length !== grant.credentialSecretRefs.length) { await tx.update(connectionGrants).set({ credentialSecretRefs, ...(ownedGrantIds.has(grant.id) || grant.status === "revoked" ? { status: "revoked" as const, isDefault: false, } : { status: "needs_reauthorization" as const, isDefault: false, }), updatedAt: now, }) .where(eq(connectionGrants.id, grant.id)); } } for (const connection of connectionRefs) { if (!affectedConnections.has(connection.id)) continue; const credentialRefs = connection.credentialRefs.filter( (ref) => !connectionSecretIdsToRemove.has(ref.secretId), ); const credentialSecretRefs = connection.credentialSecretRefs.filter( (ref) => !connectionSecretIdsToRemove.has(ref.secretId), ); if ( credentialRefs.length !== connection.credentialRefs.length || credentialSecretRefs.length !== connection.credentialSecretRefs.length ) { const hasUnaffectedActiveGrant = grantRefs.some((grant) => grant.connectionId === connection.id && !ownedGrantIds.has(grant.id) && grant.status === "active" && grant.credentialSecretRefs.length > 0 && grant.credentialSecretRefs.every((ref) => !connectionSecretIdsToRemove.has(ref.secretId)), ); await tx.update(toolConnections).set({ credentialRefs, credentialSecretRefs, ...(!hasUnaffectedActiveGrant ? { status: "draft" as const, enabled: false, healthStatus: "missing_secret" as const, healthMessage: "Personal credential owner no longer has company access. Reauthorize this connection.", lastError: "oauth_reauthorization_required", } : {}), updatedAt: now, }) .where(eq(toolConnections.id, connection.id)); } } if (affectedConnectionIds.length > 0 && connectionSecretIdsToRemove.size > 0) { await tx.delete(companySecretBindings).where(and( eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.targetType, "tool_connection"), inArray(companySecretBindings.targetId, affectedConnectionIds), inArray(companySecretBindings.secretId, [...connectionSecretIdsToRemove]), )); } if (secretIdsToDelete.length > 0) { await tx.delete(companySecrets).where(and( eq(companySecrets.companyId, companyId), inArray(companySecrets.id, secretIdsToDelete), )); } } await tx.delete(connectionGrantMembers).where(and( eq(connectionGrantMembers.companyId, companyId), eq(connectionGrantMembers.subjectType, "user"), eq(connectionGrantMembers.subjectId, userId), soleAudienceGrantIds.size > 0 ? notInArray(connectionGrantMembers.grantId, [...soleAudienceGrantIds]) : undefined, )); if (removedDelegations.length > 0) { const connectionByGrant = new Map(ownedGrants.map((grant) => [grant.id, grant.connectionId])); await tx.insert(toolAccessAuditEvents).values(removedDelegations.map((delegation) => ({ companyId, connectionId: connectionByGrant.get(delegation.grantId) ?? null, actorType: "system", actorId: null, action: "connection_grant.delegation_revoked", outcome: "success", reasonCode: "membership_removed", details: { grantId: delegation.grantId, delegationId: delegation.id, agentId: delegation.agentId, ownerUserId: userId, }, }))); } } async function isInstanceAdmin(userId: string | null | undefined): Promise { if (!userId) return false; const row = await db .select({ id: instanceUserRoles.id }) .from(instanceUserRoles) .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) .then((rows) => rows[0] ?? null); return Boolean(row); } async function getMembership( companyId: string, principalType: PrincipalType, principalId: string, ): Promise { return db .select() .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, principalType), eq(companyMemberships.principalId, principalId), ), ) .then((rows) => rows[0] ?? null); } async function hasPermission( companyId: string, principalType: PrincipalType, principalId: string, permissionKey: PermissionKey, ): Promise { return authorization.decidePrincipalGrant({ companyId, principalType, principalId, permissionKey, action: permissionKey, }).then((decision) => decision.allowed); } async function canUser( companyId: string, userId: string | null | undefined, permissionKey: PermissionKey, ): Promise { return authorization.decide({ actor: { type: "board", userId }, action: permissionKey, resource: { type: "company", companyId }, }).then((decision) => decision.allowed); } async function decide(input: { actor: AuthorizationActor; action: Parameters[0]["action"]; resource: AuthorizationResource; scope?: Record | null; }) { return authorization.decide(input); } async function listMembers(companyId: string) { return db .select() .from(companyMemberships) .where(eq(companyMemberships.companyId, companyId)) .orderBy(sql`${companyMemberships.createdAt} desc`); } async function getMemberById(companyId: string, memberId: string) { return db .select() .from(companyMemberships) .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) .then((rows) => rows[0] ?? null); } async function listActiveUserMemberships(companyId: string) { return db .select() .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"), ), ) .orderBy(sql`${companyMemberships.createdAt} asc`); } async function setMemberPermissions( companyId: string, memberId: string, grants: GrantInput[], grantedByUserId: string | null, ) { const member = await getMemberById(companyId, memberId); if (!member) return null; await db.transaction(async (tx) => { await tx .delete(principalPermissionGrants) .where( and( eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalType, member.principalType), eq(principalPermissionGrants.principalId, member.principalId), ), ); if (grants.length > 0) { await tx.insert(principalPermissionGrants).values( grants.map((grant) => ({ companyId, principalType: member.principalType, principalId: member.principalId, permissionKey: grant.permissionKey, scope: grant.scope ?? null, grantedByUserId, createdAt: new Date(), updatedAt: new Date(), })), ); } }); return member; } async function updateMemberAndPermissions( companyId: string, memberId: string, data: { membershipRole?: string | null; status?: "pending" | "active" | "suspended"; grants: GrantInput[]; }, grantedByUserId: string | null, ) { return db.transaction(async (tx) => { await tx.execute(sql` select ${companyMemberships.id} from ${companyMemberships} where ${companyMemberships.companyId} = ${companyId} and ${companyMemberships.principalType} = 'user' and ${companyMemberships.status} = 'active' and ${companyMemberships.membershipRole} = 'owner' for update `); const existing = await tx .select() .from(companyMemberships) .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) .for("update") .then((rows) => rows[0] ?? null); if (!existing) return null; const nextMembershipRole = data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole; const nextStatus = data.status ?? existing.status; if ( existing.principalType === "user" && existing.status === "active" && existing.membershipRole === "owner" && (nextStatus !== "active" || nextMembershipRole !== "owner") ) { const activeOwnerCount = await tx .select({ id: companyMemberships.id }) .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"), eq(companyMemberships.membershipRole, "owner"), ), ) .then((rows) => rows.length); if (activeOwnerCount <= 1) { throw conflict("Cannot remove the last active owner"); } } const now = new Date(); if (existing.status === "active" && nextStatus !== "active" && existing.principalType === "user") { await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now); } const updated = await tx .update(companyMemberships) .set({ membershipRole: nextMembershipRole, status: nextStatus, updatedAt: now, }) .where(eq(companyMemberships.id, existing.id)) .returning() .then((rows) => rows[0] ?? existing); await tx .delete(principalPermissionGrants) .where( and( eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalType, existing.principalType), eq(principalPermissionGrants.principalId, existing.principalId), ), ); if (data.grants.length > 0) { await tx.insert(principalPermissionGrants).values( data.grants.map((grant) => ({ companyId, principalType: existing.principalType, principalId: existing.principalId, permissionKey: grant.permissionKey, scope: grant.scope ?? null, grantedByUserId, createdAt: now, updatedAt: now, })), ); } return updated; }); } async function assertCanRemoveActiveOwner( companyId: string, principalType: PrincipalType, status: string, membershipRole: string | null, tx: Pick, ) { if ( principalType !== "user" || status !== "active" || membershipRole !== "owner" ) { return; } const activeOwnerCount = await tx .select({ id: companyMemberships.id }) .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"), eq(companyMemberships.membershipRole, "owner"), ), ) .then((rows) => rows.length); if (activeOwnerCount <= 1) { throw conflict("Cannot remove the last active owner"); } } async function assertAssignableArchiveTarget( companyId: string, input: MemberArchiveInput["reassignment"], tx: Pick, ) { if (!input?.assigneeAgentId && !input?.assigneeUserId) return; if (input.assigneeAgentId && input.assigneeUserId) { throw conflict("Choose either an agent or user reassignment target"); } if (input.assigneeUserId) { const membership = await tx .select({ id: companyMemberships.id }) .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, input.assigneeUserId), eq(companyMemberships.status, "active"), ), ) .then((rows) => rows[0] ?? null); if (!membership) { throw conflict("Replacement user must be an active company member"); } return; } await assertAssignableAgent(tx as Db, companyId, input.assigneeAgentId, { kind: "work" }); } async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) { return db.transaction(async (tx) => { await tx.execute(sql` select ${companyMemberships.id} from ${companyMemberships} where ${companyMemberships.companyId} = ${companyId} and ${companyMemberships.principalType} = 'user' and ${companyMemberships.status} = 'active' and ${companyMemberships.membershipRole} = 'owner' for update `); const existing = await tx .select() .from(companyMemberships) .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) .for("update") .then((rows) => rows[0] ?? null); if (!existing) return null; if (existing.principalType !== "user") { throw conflict("Only human company members can be archived"); } if (existing.status === "archived") { return { member: existing, reassignedIssueCount: 0 }; } if (input.reassignment?.assigneeUserId === existing.principalId) { throw conflict("Replacement user cannot be the archived member"); } await assertCanRemoveActiveOwner( companyId, existing.principalType, existing.status, existing.membershipRole, tx, ); await assertAssignableArchiveTarget(companyId, input.reassignment, tx); const now = new Date(); await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now); const assignmentPatch = { assigneeAgentId: input.reassignment?.assigneeAgentId ?? null, assigneeUserId: input.reassignment?.assigneeUserId ?? null, updatedAt: now, }; const assignedOpenIssueWhere = and( eq(issues.companyId, companyId), eq(issues.assigneeUserId, existing.principalId), sql`${issues.status} not in ('done', 'cancelled')`, ); const resetInProgress = await tx .update(issues) .set({ ...assignmentPatch, status: "todo", startedAt: null, checkoutRunId: null, executionRunId: null, executionLockedAt: null, }) .where(and(assignedOpenIssueWhere, eq(issues.status, "in_progress"))) .returning({ id: issues.id }); const reassigned = await tx .update(issues) .set(assignmentPatch) .where(and(assignedOpenIssueWhere, ne(issues.status, "in_progress"))) .returning({ id: issues.id }); await tx .delete(principalPermissionGrants) .where( and( eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalType, existing.principalType), eq(principalPermissionGrants.principalId, existing.principalId), ), ); const archived = await tx .update(companyMemberships) .set({ status: "archived", updatedAt: now, }) .where(eq(companyMemberships.id, existing.id)) .returning() .then((rows) => rows[0] ?? existing); return { member: archived, reassignedIssueCount: resetInProgress.length + reassigned.length, }; }); } async function promoteInstanceAdmin(userId: string) { const existing = await db .select() .from(instanceUserRoles) .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) .then((rows) => rows[0] ?? null); if (existing) return existing; return db .insert(instanceUserRoles) .values({ userId, role: "instance_admin", }) .returning() .then((rows) => rows[0]); } async function demoteInstanceAdmin(userId: string) { return db .delete(instanceUserRoles) .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) .returning() .then((rows) => rows[0] ?? null); } async function listUserCompanyAccess(userId: string) { return db .select() .from(companyMemberships) .where(and(eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId))) .orderBy(sql`${companyMemberships.createdAt} desc`); } async function setUserCompanyAccess( userId: string, companyIds: string[], options: { actorUserId?: string | null } = {}, ) { const target = new Set(companyIds); await db.transaction(async (tx) => { // Serialize every company-access removal/reactivation with personal OAuth // completion, which locks the same membership row before writing secrets. const existing = await tx .select() .from(companyMemberships) .where(and(eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId))) .for("update"); const existingByCompany = new Map(existing.map((row) => [row.companyId, row])); const toArchive = existing.filter((row) => !target.has(row.companyId) && row.status !== "archived"); if (toArchive.length > 0 && options.actorUserId && options.actorUserId === userId) { throw conflict("You cannot remove yourself"); } if (toArchive.length > 0 && (await isInstanceAdmin(userId))) { throw conflict("Instance admins cannot be removed from company access"); } const protectedArchives = toArchive.filter((row) => row.membershipRole === "owner" || row.membershipRole === "admin"); if (protectedArchives.length > 0) { throw conflict("Owners and admins cannot be removed from company access"); } const activeOwnerArchives = toArchive.filter( (row) => row.status === "active" && row.membershipRole === "owner", ); if (activeOwnerArchives.length > 0) { const activeOwnerRows = await tx .select({ companyId: companyMemberships.companyId, id: companyMemberships.id }) .from(companyMemberships) .where( and( eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"), eq(companyMemberships.membershipRole, "owner"), inArray(companyMemberships.companyId, activeOwnerArchives.map((row) => row.companyId)), ), ); for (const row of activeOwnerArchives) { const remainingOwners = activeOwnerRows.filter((owner) => owner.companyId === row.companyId).length - 1; if (remainingOwners <= 0) { throw conflict("Cannot remove the last active owner"); } } } if (toArchive.length > 0) { const now = new Date(); for (const membership of toArchive) { await sweepMemberConnectionAccess(tx, membership.companyId, membership.principalId, now); } await tx .update(companyMemberships) .set({ status: "archived", updatedAt: now }) .where(inArray(companyMemberships.id, toArchive.map((row) => row.id))); await tx .delete(principalPermissionGrants) .where( and( eq(principalPermissionGrants.principalType, "user"), eq(principalPermissionGrants.principalId, userId), inArray(principalPermissionGrants.companyId, toArchive.map((row) => row.companyId)), ), ); } for (const companyId of target) { const existingMembership = existingByCompany.get(companyId); if (existingMembership) { if (existingMembership.status !== "active") { await tx .update(companyMemberships) .set({ status: "active", membershipRole: existingMembership.membershipRole ?? "operator", updatedAt: new Date(), }) .where(eq(companyMemberships.id, existingMembership.id)); } continue; } await tx.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "operator", }); } }); return listUserCompanyAccess(userId); } async function ensureMembership( companyId: string, principalType: PrincipalType, principalId: string, membershipRole: string | null = "member", status: "pending" | "active" | "suspended" = "active", ) { const existing = await getMembership(companyId, principalType, principalId); if (existing) { if (existing.status !== status || existing.membershipRole !== membershipRole) { const updated = await db .update(companyMemberships) .set({ status, membershipRole, updatedAt: new Date() }) .where(eq(companyMemberships.id, existing.id)) .returning() .then((rows) => rows[0] ?? null); return updated ?? existing; } return existing; } return db .insert(companyMemberships) .values({ companyId, principalType, principalId, status, membershipRole, }) .returning() .then((rows) => rows[0]); } async function setPrincipalGrants( companyId: string, principalType: PrincipalType, principalId: string, grants: GrantInput[], grantedByUserId: string | null, ) { await db.transaction(async (tx) => { await tx .delete(principalPermissionGrants) .where( and( eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalType, principalType), eq(principalPermissionGrants.principalId, principalId), ), ); if (grants.length === 0) return; await tx.insert(principalPermissionGrants).values( grants.map((grant) => ({ companyId, principalType, principalId, permissionKey: grant.permissionKey, scope: grant.scope ?? null, grantedByUserId, createdAt: new Date(), updatedAt: new Date(), })), ); }); } async function copyActiveUserMemberships(sourceCompanyId: string, targetCompanyId: string) { const sourceMemberships = await listActiveUserMemberships(sourceCompanyId); for (const membership of sourceMemberships) { await ensureMembership( targetCompanyId, "user", membership.principalId, membership.membershipRole, "active", ); await ensureHumanRoleDefaultGrants(db, { companyId: targetCompanyId, principalId: membership.principalId, membershipRole: membership.membershipRole, grantedByUserId: null, }); } return sourceMemberships; } async function ensureRoleDefaultGrants( companyId: string, principalId: string, membershipRole: string | null | undefined, grantedByUserId: string | null, ) { return ensureHumanRoleDefaultGrants(db, { companyId, principalId, membershipRole, grantedByUserId, }); } async function listPrincipalGrants( companyId: string, principalType: PrincipalType, principalId: string, ) { return db .select() .from(principalPermissionGrants) .where( and( eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalType, principalType), eq(principalPermissionGrants.principalId, principalId), ), ) .orderBy(principalPermissionGrants.permissionKey); } async function setPrincipalPermission( companyId: string, principalType: PrincipalType, principalId: string, permissionKey: PermissionKey, enabled: boolean, grantedByUserId: string | null, scope: Record | null = null, ) { if (!enabled) { await db .delete(principalPermissionGrants) .where( and( eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalType, principalType), eq(principalPermissionGrants.principalId, principalId), eq(principalPermissionGrants.permissionKey, permissionKey), ), ); return; } await ensureMembership(companyId, principalType, principalId, "member", "active"); const existing = await db .select() .from(principalPermissionGrants) .where( and( eq(principalPermissionGrants.companyId, companyId), eq(principalPermissionGrants.principalType, principalType), eq(principalPermissionGrants.principalId, principalId), eq(principalPermissionGrants.permissionKey, permissionKey), ), ) .then((rows) => rows[0] ?? null); if (existing) { await db .update(principalPermissionGrants) .set({ scope, grantedByUserId, updatedAt: new Date(), }) .where(eq(principalPermissionGrants.id, existing.id)); return; } await db.insert(principalPermissionGrants).values({ companyId, principalType, principalId, permissionKey, scope, grantedByUserId, createdAt: new Date(), updatedAt: new Date(), }); } async function updateMember( companyId: string, memberId: string, data: { membershipRole?: string | null; status?: "pending" | "active" | "suspended"; }, ) { return db.transaction(async (tx) => { await tx.execute(sql` select ${companyMemberships.id} from ${companyMemberships} where ${companyMemberships.companyId} = ${companyId} and ${companyMemberships.principalType} = 'user' and ${companyMemberships.status} = 'active' and ${companyMemberships.membershipRole} = 'owner' for update `); const existing = await tx .select() .from(companyMemberships) .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) .for("update") .then((rows) => rows[0] ?? null); if (!existing) return null; const nextMembershipRole = data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole; const nextStatus = data.status ?? existing.status; if ( existing.principalType === "user" && existing.status === "active" && existing.membershipRole === "owner" && (nextStatus !== "active" || nextMembershipRole !== "owner") ) { const activeOwnerCount = await tx .select({ id: companyMemberships.id }) .from(companyMemberships) .where( and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"), eq(companyMemberships.membershipRole, "owner"), ), ) .then((rows) => rows.length); if (activeOwnerCount <= 1) { throw conflict("Cannot remove the last active owner"); } } const now = new Date(); if ( existing.principalType === "user" && existing.status !== "suspended" && nextStatus === "suspended" ) { await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now); } return tx .update(companyMemberships) .set({ membershipRole: nextMembershipRole, status: nextStatus, updatedAt: now, }) .where(eq(companyMemberships.id, existing.id)) .returning() .then((rows) => rows[0] ?? existing); }); } return { isInstanceAdmin, decide, canUser, hasPermission, getMembership, getMemberById, ensureMembership, listMembers, listActiveUserMemberships, copyActiveUserMemberships, ensureRoleDefaultGrants, archiveMember, setMemberPermissions, updateMemberAndPermissions, promoteInstanceAdmin, demoteInstanceAdmin, listUserCompanyAccess, setUserCompanyAccess, setPrincipalGrants, listPrincipalGrants, setPrincipalPermission, updateMember, }; }