forked from bai/curriculum-project-hub
b574ef871c
Archiving a team no longer cascade-revokes its active TEAM->PROJECT grants and memberships. The archived flag alone makes the team principal unresolvable (permissions/principals.ts refuses archived teams), so the dead grant/membership rows confer no access. listProjectTeamAccess now filters archived teams out of the project view instead of relying on a revokedAt cascade, and the org-admin teams page confirm copy is updated. archiveTeam drops the revokedGrants count from its return shape. ADR-0019 / Spec.System.Organization: principal resolution, not grant mutation, is the access boundary for archived teams.
223 lines
6.9 KiB
TypeScript
223 lines
6.9 KiB
TypeScript
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<ProjectTeamAccessEntry> {
|
|
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<number> {
|
|
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<readonly ProjectTeamAccessEntry[]> {
|
|
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,
|
|
};
|
|
}
|