forked from bai/curriculum-project-hub
feat: add organization tenant model
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import type { PermissionRole, Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
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);
|
||||
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);
|
||||
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) },
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { id: true, slug: true, name: 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.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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user