forked from bai/curriculum-project-hub
adce8fb6f5
ADR-0023 / Spec.System.PlatformAdministration pins the platform
administrator as a separate identity/session/audit control plane,
intentionally not modeled in alpha (ADR-0025). The legacy
PlatformRoleAssignment / PlatformRole{ADMIN,TEACHER} table had no runtime
reader (no guard, route, or service queried it for an authorization
decision) and ADR-0023 requires it to be replaced before the platform
panel ships.
Drop the model, the PlatformRole enum, the User.platformRoles relation,
and the migration. Stop seeding platformRoles in externalSync principal
ingestion and the integration test helper. Update the doc comments on
OrganizationMembership and PermissionRole to point at the platform-admin
control plane instead of the dropped model.
The 20260709180000_organization_tenant_root backfill only referenced
PlatformRoleAssignment in a one-time INSERT...SELECT; no persistent
object references it, so dropping the table is safe after that migration.
164 lines
5.6 KiB
TypeScript
164 lines
5.6 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,
|
|
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}`);
|
|
}
|
|
}
|