import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client"; import { lockActiveOrganization } from "../org/status.js"; export interface GrantTeamProjectAccessInput { readonly projectId: string; readonly teamId?: string | undefined; readonly teamSlug?: string | undefined; readonly role: PermissionRole; readonly createdByUserId?: string | undefined; } export interface RevokeTeamProjectAccessInput { readonly projectId: string; readonly teamId?: string | undefined; readonly teamSlug?: string | undefined; } export interface ProjectTeamAccessEntry { readonly grantId: string; readonly projectId: string; readonly organizationId: string; readonly teamId: string; readonly teamSlug: string; readonly teamName: string; readonly role: PermissionRole; } export async function grantTeamProjectAccess( prisma: PrismaClient, input: GrantTeamProjectAccessInput, ): Promise { return prisma.$transaction(async (tx) => { const { project, team } = await resolveProjectAndTeam(tx, input); await lockActiveOrganization(tx, project.organizationId); const now = new Date(); const existing = await tx.permissionGrant.findFirst({ where: { resourceType: "PROJECT", resourceId: project.id, principalType: "TEAM", principalId: team.id, revokedAt: null, }, select: { id: true, role: true }, }); if (existing?.role === input.role) { return entryFromGrant({ grantId: existing.id, projectId: project.id, organizationId: project.organizationId, team, role: existing.role, }); } await tx.permissionGrant.updateMany({ where: { resourceType: "PROJECT", resourceId: project.id, principalType: "TEAM", principalId: team.id, revokedAt: null, }, data: { revokedAt: now }, }); const grant = await tx.permissionGrant.create({ data: { resourceType: "PROJECT", resourceId: project.id, principalType: "TEAM", principalId: team.id, role: input.role, ...(input.createdByUserId !== undefined ? { createdByUserId: input.createdByUserId } : {}), }, select: { id: true, role: true }, }); return entryFromGrant({ grantId: grant.id, projectId: project.id, organizationId: project.organizationId, team, role: grant.role, }); }); } export async function revokeTeamProjectAccess( prisma: PrismaClient, input: RevokeTeamProjectAccessInput, ): Promise { return prisma.$transaction(async (tx) => { const { project, team } = await resolveProjectAndTeam(tx, input); await lockActiveOrganization(tx, project.organizationId); const result = await tx.permissionGrant.updateMany({ where: { resourceType: "PROJECT", resourceId: project.id, principalType: "TEAM", principalId: team.id, revokedAt: null, }, data: { revokedAt: new Date() }, }); return result.count; }); } export async function listProjectTeamAccess( prisma: PrismaClient, projectId: string, ): Promise { const project = await prisma.project.findUnique({ where: { id: projectId }, select: { id: true, organizationId: true }, }); if (project === null) { throw new Error(`project not found: ${projectId}`); } const grants = await prisma.permissionGrant.findMany({ where: { resourceType: "PROJECT", resourceId: projectId, principalType: "TEAM", revokedAt: null, }, select: { id: true, principalId: true, role: true }, orderBy: { createdAt: "asc" }, }); if (grants.length === 0) return []; const teams = await prisma.team.findMany({ where: { organizationId: project.organizationId, id: { in: grants.map((grant) => grant.principalId) }, }, select: { id: true, slug: true, name: true, archivedAt: true }, }); const teamsById = new Map(teams.map((team) => [team.id, team])); const missing = grants.filter((grant) => !teamsById.has(grant.principalId)); if (missing.length > 0) { throw new Error( `project ${projectId} has active TEAM grants outside organization ${project.organizationId}: ${missing.map((grant) => grant.principalId).join(", ")}`, ); } return grants .filter((grant) => teamsById.get(grant.principalId)?.archivedAt === null) .map((grant) => entryFromGrant({ grantId: grant.id, projectId: project.id, organizationId: project.organizationId, team: teamsById.get(grant.principalId)!, role: grant.role, })); } type ProjectForAccess = { readonly id: string; readonly organizationId: string; }; type TeamForAccess = { readonly id: string; readonly slug: string; readonly name: string; }; async function resolveProjectAndTeam( prisma: PrismaClient | Prisma.TransactionClient, input: { readonly projectId: string; readonly teamId?: string | undefined; readonly teamSlug?: string | undefined }, ): Promise<{ readonly project: ProjectForAccess; readonly team: TeamForAccess }> { if ((input.teamId === undefined || input.teamId === "") && (input.teamSlug === undefined || input.teamSlug === "")) { throw new Error("granting team project access requires teamId or teamSlug"); } if (input.teamId !== undefined && input.teamId !== "" && input.teamSlug !== undefined && input.teamSlug !== "") { throw new Error("granting team project access accepts only one of teamId or teamSlug"); } const project = await prisma.project.findUnique({ where: { id: input.projectId }, select: { id: true, organizationId: true }, }); if (project === null) { throw new Error(`project not found: ${input.projectId}`); } const team = await prisma.team.findFirst({ where: input.teamId !== undefined && input.teamId !== "" ? { id: input.teamId, archivedAt: null } : { organizationId: project.organizationId, slug: input.teamSlug!, archivedAt: null }, select: { id: true, slug: true, name: true, organizationId: true }, }); if (team === null) { const label = input.teamId ?? input.teamSlug; throw new Error(`active team not found for project ${input.projectId}: ${label}`); } if (team.organizationId !== project.organizationId) { throw new Error( `cross-organization team grant refused: project ${project.id} is in ${project.organizationId}, team ${team.id} is in ${team.organizationId}`, ); } return { project, team }; } function entryFromGrant(input: { readonly grantId: string; readonly projectId: string; readonly organizationId: string; readonly team: TeamForAccess; readonly role: PermissionRole; }): ProjectTeamAccessEntry { return { grantId: input.grantId, projectId: input.projectId, organizationId: input.organizationId, teamId: input.team.id, teamSlug: input.team.slug, teamName: input.team.name, role: input.role, }; }