forked from EduCraft/curriculum-project-hub
feat: add organization tenant model
This commit is contained in:
@@ -38,6 +38,7 @@ export interface AuthorizationDecision {
|
||||
readonly action: AuthorizationAction;
|
||||
readonly resource: AuthorizationResource;
|
||||
readonly actor: ActorInput;
|
||||
readonly organizationId: string;
|
||||
readonly actorUserId?: string | undefined;
|
||||
readonly principals: readonly PrincipalRef[];
|
||||
readonly requiredRole: PermissionRole;
|
||||
@@ -60,7 +61,8 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
||||
if (req.action === "role.trigger" && (req.roleId === undefined || req.roleId === "")) {
|
||||
throw new Error("role.trigger authorization requires roleId");
|
||||
}
|
||||
const resolution = await this.resolver.resolveActor(req.actor);
|
||||
const organizationId = await this.organizationForResource(req.resource);
|
||||
const resolution = await this.resolver.resolveActor(req.actor, { organizationId });
|
||||
const threshold = await this.requiredRole(req);
|
||||
if (threshold === "DISABLED") {
|
||||
return this.decision(req, resolution, {
|
||||
@@ -206,17 +208,45 @@ export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
|
||||
private decision(
|
||||
req: AuthorizationRequest,
|
||||
resolution: PrincipalResolution,
|
||||
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "actorUserId" | "principals">,
|
||||
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "organizationId" | "actorUserId" | "principals">,
|
||||
): AuthorizationDecision {
|
||||
return {
|
||||
action: req.action,
|
||||
resource: req.resource,
|
||||
actor: req.actor,
|
||||
organizationId: resolution.organizationId,
|
||||
...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}),
|
||||
principals: resolution.principals,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
private async organizationForResource(resource: AuthorizationResource): Promise<string> {
|
||||
switch (resource.type) {
|
||||
case "PROJECT": {
|
||||
const project = await this.prisma.project.findUnique({
|
||||
where: { id: resource.id },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (project === null) {
|
||||
throw new Error(`authorization resource missing: PROJECT ${resource.id}`);
|
||||
}
|
||||
return project.organizationId;
|
||||
}
|
||||
case "PROJECT_GROUP": {
|
||||
const binding = await this.prisma.projectGroupBinding.findUnique({
|
||||
where: { chatId: resource.id },
|
||||
select: { project: { select: { organizationId: true } } },
|
||||
});
|
||||
if (binding === null) {
|
||||
throw new Error(`authorization resource missing: PROJECT_GROUP ${resource.id}`);
|
||||
}
|
||||
return binding.project.organizationId;
|
||||
}
|
||||
case "ARTIFACT":
|
||||
throw new Error("authorization for ARTIFACT resources requires an organization resolver");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPermissionAuthorizer(
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface ExternalPrincipalMembershipInput {
|
||||
}
|
||||
|
||||
export interface SyncExternalPrincipalMembershipsInput {
|
||||
readonly organizationId: string;
|
||||
readonly provider?: string | undefined;
|
||||
readonly providerTenantId?: string | undefined;
|
||||
readonly source: string;
|
||||
readonly replaceSource?: boolean | undefined;
|
||||
readonly memberships: readonly ExternalPrincipalMembershipInput[];
|
||||
@@ -30,6 +33,26 @@ export async function syncExternalPrincipalMemberships(
|
||||
const syncedAt = new Date();
|
||||
const incomingKeys = new Set<string>();
|
||||
let synced = 0;
|
||||
const connection = await prisma.externalDirectoryConnection.upsert({
|
||||
where: {
|
||||
organizationId_provider_source: {
|
||||
organizationId: input.organizationId,
|
||||
provider: input.provider ?? "FEISHU",
|
||||
source: input.source,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
revokedAt: null,
|
||||
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
|
||||
},
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
provider: input.provider ?? "FEISHU",
|
||||
...(input.providerTenantId !== undefined ? { providerTenantId: input.providerTenantId } : {}),
|
||||
source: input.source,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const item of input.memberships) {
|
||||
assertExternalPrincipalType(item.principalType);
|
||||
@@ -40,15 +63,17 @@ export async function syncExternalPrincipalMemberships(
|
||||
feishuOpenId: item.feishuOpenId,
|
||||
displayName: item.displayName,
|
||||
platformRoles: { create: { role: "TEACHER" } },
|
||||
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
|
||||
},
|
||||
});
|
||||
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId));
|
||||
await ensureOrganizationMembership(prisma, input.organizationId, user.id);
|
||||
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId, connection.id));
|
||||
const existing = await prisma.externalPrincipalMembership.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
source: input.source,
|
||||
connectionId: connection.id,
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
@@ -57,9 +82,9 @@ export async function syncExternalPrincipalMemberships(
|
||||
await prisma.externalPrincipalMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
connectionId: connection.id,
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
source: input.source,
|
||||
syncedAt,
|
||||
},
|
||||
});
|
||||
@@ -75,14 +100,15 @@ export async function syncExternalPrincipalMemberships(
|
||||
let revoked = 0;
|
||||
if (input.replaceSource === true) {
|
||||
const active = await prisma.externalPrincipalMembership.findMany({
|
||||
where: { source: input.source, revokedAt: null },
|
||||
select: { id: true, userId: true, principalType: true, principalId: true },
|
||||
where: { connectionId: connection.id, revokedAt: null },
|
||||
select: { id: true, userId: true, principalType: true, principalId: true, connectionId: true },
|
||||
});
|
||||
const revokeIds = active
|
||||
.filter((membership) => !incomingKeys.has(externalMembershipKey(
|
||||
membership.userId,
|
||||
membership.principalType,
|
||||
membership.principalId,
|
||||
membership.connectionId,
|
||||
)))
|
||||
.map((membership) => membership.id);
|
||||
if (revokeIds.length > 0) {
|
||||
@@ -97,8 +123,23 @@ export async function syncExternalPrincipalMemberships(
|
||||
return { synced, revoked };
|
||||
}
|
||||
|
||||
function externalMembershipKey(userId: string, type: PrincipalType, id: string): string {
|
||||
return `${userId}:${type}:${id}`;
|
||||
async function ensureOrganizationMembership(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const active = await prisma.organizationMembership.findFirst({
|
||||
where: { organizationId, userId, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (active !== null) return;
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId, userId, role: "MEMBER" },
|
||||
});
|
||||
}
|
||||
|
||||
function externalMembershipKey(userId: string, type: PrincipalType, id: string, connectionId: string): string {
|
||||
return `${connectionId}:${userId}:${type}:${id}`;
|
||||
}
|
||||
|
||||
function assertExternalPrincipalType(type: PrincipalType): asserts type is ExternalPrincipalType {
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface ActorInput {
|
||||
readonly chatId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface PrincipalResolutionScope {
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
export type PrincipalSource =
|
||||
| "actor-user"
|
||||
| "context-chat"
|
||||
@@ -25,19 +29,20 @@ export interface ResolvedPrincipal {
|
||||
|
||||
export interface PrincipalResolution {
|
||||
readonly actor: ActorInput;
|
||||
readonly organizationId: string;
|
||||
readonly userId?: string | undefined;
|
||||
readonly principals: readonly PrincipalRef[];
|
||||
readonly resolved: readonly ResolvedPrincipal[];
|
||||
}
|
||||
|
||||
export interface PrincipalResolver {
|
||||
resolveActor(actor: ActorInput): Promise<PrincipalResolution>;
|
||||
resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution>;
|
||||
}
|
||||
|
||||
export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async resolveActor(actor: ActorInput): Promise<PrincipalResolution> {
|
||||
async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> {
|
||||
const resolved = new PrincipalCollector();
|
||||
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
|
||||
if (actor.chatId !== undefined && actor.chatId !== "") {
|
||||
@@ -51,7 +56,11 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||
|
||||
if (user !== null) {
|
||||
const memberships = await this.prisma.teamMembership.findMany({
|
||||
where: { userId: user.id, revokedAt: null, team: { archivedAt: null } },
|
||||
where: {
|
||||
userId: user.id,
|
||||
revokedAt: null,
|
||||
team: { organizationId: scope.organizationId, archivedAt: null },
|
||||
},
|
||||
select: { teamId: true },
|
||||
});
|
||||
for (const membership of memberships) {
|
||||
@@ -59,7 +68,14 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||
}
|
||||
|
||||
const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
where: {
|
||||
userId: user.id,
|
||||
revokedAt: null,
|
||||
connection: {
|
||||
organizationId: scope.organizationId,
|
||||
revokedAt: null,
|
||||
},
|
||||
},
|
||||
select: { principalType: true, principalId: true },
|
||||
});
|
||||
for (const membership of externalMemberships) {
|
||||
@@ -75,7 +91,7 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||
const bindings = await this.prisma.teamExternalBinding.findMany({
|
||||
where: {
|
||||
revokedAt: null,
|
||||
team: { archivedAt: null },
|
||||
team: { organizationId: scope.organizationId, archivedAt: null },
|
||||
OR: externalPrincipals.map((principal) => ({
|
||||
principalType: principal.type,
|
||||
principalId: principal.id,
|
||||
@@ -94,6 +110,7 @@ export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||
|
||||
return {
|
||||
actor,
|
||||
organizationId: scope.organizationId,
|
||||
...(user !== null ? { userId: user.id } : {}),
|
||||
principals: resolved.principals(),
|
||||
resolved: resolved.entries(),
|
||||
|
||||
@@ -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