Files
curriculum-project-hub/hub/src/permissions/externalSync.ts
T

165 lines
5.7 KiB
TypeScript

import type { Prisma, PrismaClient, PrincipalType } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
export type ExternalPrincipalType = Extract<
PrincipalType,
"FEISHU_CHAT" | "FEISHU_DEPARTMENT" | "FEISHU_USER_GROUP"
>;
export interface ExternalPrincipalMembershipInput {
readonly feishuOpenId: string;
readonly displayName: string;
readonly principalType: ExternalPrincipalType;
readonly principalId: string;
}
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[];
}
export interface SyncExternalPrincipalMembershipsResult {
readonly synced: number;
readonly revoked: number;
}
export async function syncExternalPrincipalMemberships(
prisma: PrismaClient,
input: SyncExternalPrincipalMembershipsInput,
): Promise<SyncExternalPrincipalMembershipsResult> {
const syncedAt = new Date();
const incomingKeys = new Set<string>();
let synced = 0;
const connection = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
return tx.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 },
});
});
// Each membership is a short lifecycle-serialized unit. A large directory
// must not hold the Organization row lock (or one interactive transaction)
// for the duration of the entire external sync.
for (const item of input.memberships) {
assertExternalPrincipalType(item.principalType);
const userId = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const user = await tx.user.upsert({
where: { feishuOpenId: item.feishuOpenId },
update: { displayName: item.displayName },
create: {
feishuOpenId: item.feishuOpenId,
displayName: item.displayName,
platformRoles: { create: { role: "TEACHER" } },
organizationMemberships: { create: { organizationId: input.organizationId, role: "MEMBER" } },
},
});
await ensureOrganizationMembership(tx, input.organizationId, user.id);
const existing = await tx.externalPrincipalMembership.findFirst({
where: {
userId: user.id,
principalType: item.principalType,
principalId: item.principalId,
connectionId: connection.id,
revokedAt: null,
},
select: { id: true },
});
if (existing === null) {
await tx.externalPrincipalMembership.create({
data: {
userId: user.id,
connectionId: connection.id,
principalType: item.principalType,
principalId: item.principalId,
syncedAt,
},
});
} else {
await tx.externalPrincipalMembership.update({
where: { id: existing.id },
data: { syncedAt },
});
}
return user.id;
});
incomingKeys.add(externalMembershipKey(userId, item.principalType, item.principalId, connection.id));
synced++;
}
let revoked = 0;
if (input.replaceSource === true) {
revoked = await prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, input.organizationId);
const active = await tx.externalPrincipalMembership.findMany({
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) {
const result = await tx.externalPrincipalMembership.updateMany({
where: { id: { in: revokeIds } },
data: { revokedAt: syncedAt },
});
return result.count;
}
return 0;
});
}
return { synced, revoked };
}
async function ensureOrganizationMembership(
prisma: PrismaClient | Prisma.TransactionClient,
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 {
if (type !== "FEISHU_CHAT" && type !== "FEISHU_DEPARTMENT" && type !== "FEISHU_USER_GROUP") {
throw new Error(`unsupported external principal type: ${type}`);
}
}