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

195 lines
6.4 KiB
TypeScript

import type { PrismaClient, PrincipalType } from "@prisma/client";
import { scopedFeishuPrincipalId } from "../feishu/identityNamespace.js";
export interface PrincipalRef {
readonly type: PrincipalType;
readonly id: string;
}
export interface ActorInput {
readonly feishuOpenId: string;
readonly chatId?: string | undefined;
}
export interface PrincipalResolutionScope {
readonly organizationId: string;
}
export type PrincipalSource =
| "actor-user"
| "context-chat"
| "team-membership"
| "external-membership"
| "team-external-binding";
export interface ResolvedPrincipal {
readonly principal: PrincipalRef;
readonly source: PrincipalSource;
readonly via?: PrincipalRef | undefined;
}
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, scope: PrincipalResolutionScope): Promise<PrincipalResolution>;
}
export class PrismaPrincipalResolver implements PrincipalResolver {
constructor(
private readonly prisma: PrismaClient,
private readonly allowLegacyIdentity = process.env["NODE_ENV"] !== "production",
) {}
async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> {
const resolved = new PrincipalCollector();
const scopedIdentity = await this.prisma.feishuUserIdentity.findFirst({
where: {
openId: actor.feishuOpenId,
connection: {
organizationId: scope.organizationId,
status: "ACTIVE",
activeSecretVersion: { retiredAt: null },
},
},
select: { userId: true, connectionId: true },
});
// Explicit migration adapter for pre-ADR-0024 fixtures/data. Alpha Silo
// ingress always has an ACTIVE connection-scoped identity.
const user = scopedIdentity === null && this.allowLegacyIdentity
? await this.prisma.user.findUnique({
where: { feishuOpenId: actor.feishuOpenId },
select: { id: true },
})
: scopedIdentity === null ? null : { id: scopedIdentity.userId };
const connection = await this.prisma.organizationFeishuApplicationConnection.findUnique({
where: { organizationId: scope.organizationId },
select: {
id: true,
status: true,
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
},
});
const activeConnectionId = scopedIdentity?.connectionId ?? (
connection?.status === "ACTIVE" && connection.activeSecretVersion !== null &&
connection.activeSecretVersion.connectionId === connection.id &&
connection.activeSecretVersion.retiredAt === null
? connection.id
: undefined
);
if (activeConnectionId === undefined && !this.allowLegacyIdentity) {
throw new Error(`active Feishu Application Connection not found for organization ${scope.organizationId}`);
}
const userPrincipalId = activeConnectionId === undefined
? actor.feishuOpenId
: scopedFeishuPrincipalId("USER", activeConnectionId, actor.feishuOpenId);
resolved.add({ type: "USER", id: userPrincipalId }, "actor-user");
if (actor.chatId !== undefined && actor.chatId !== "") {
const chatPrincipalId = activeConnectionId === undefined
? actor.chatId
: scopedFeishuPrincipalId("CHAT", activeConnectionId, actor.chatId);
resolved.add({ type: "FEISHU_CHAT", id: chatPrincipalId }, "context-chat");
}
if (user !== null) {
const memberships = await this.prisma.teamMembership.findMany({
where: {
userId: user.id,
revokedAt: null,
team: { organizationId: scope.organizationId, archivedAt: null },
},
select: { teamId: true },
});
for (const membership of memberships) {
resolved.add({ type: "TEAM", id: membership.teamId }, "team-membership");
}
const externalMemberships = await this.prisma.externalPrincipalMembership.findMany({
where: {
userId: user.id,
revokedAt: null,
connection: {
organizationId: scope.organizationId,
revokedAt: null,
},
},
select: { principalType: true, principalId: true },
});
for (const membership of externalMemberships) {
resolved.add(
{ type: membership.principalType, id: membership.principalId },
"external-membership",
);
}
}
const externalPrincipals = resolved.principals().filter((principal) => isExternalPrincipal(principal.type));
if (externalPrincipals.length > 0) {
const bindings = await this.prisma.teamExternalBinding.findMany({
where: {
revokedAt: null,
team: { organizationId: scope.organizationId, archivedAt: null },
OR: externalPrincipals.map((principal) => ({
principalType: principal.type,
principalId: principal.id,
})),
},
select: { teamId: true, principalType: true, principalId: true },
});
for (const binding of bindings) {
resolved.add(
{ type: "TEAM", id: binding.teamId },
"team-external-binding",
{ type: binding.principalType, id: binding.principalId },
);
}
}
return {
actor,
organizationId: scope.organizationId,
...(user !== null ? { userId: user.id } : {}),
principals: resolved.principals(),
resolved: resolved.entries(),
};
}
}
export function principalKey(principal: PrincipalRef): string {
return `${principal.type}:${principal.id}`;
}
function isExternalPrincipal(type: PrincipalType): boolean {
return type === "FEISHU_CHAT" || type === "FEISHU_DEPARTMENT" || type === "FEISHU_USER_GROUP";
}
class PrincipalCollector {
private readonly refs = new Map<string, PrincipalRef>();
private readonly provenance: ResolvedPrincipal[] = [];
add(principal: PrincipalRef, source: PrincipalSource, via?: PrincipalRef): void {
const key = principalKey(principal);
if (!this.refs.has(key)) {
this.refs.set(key, principal);
}
this.provenance.push({
principal,
source,
...(via !== undefined ? { via } : {}),
});
}
principals(): readonly PrincipalRef[] {
return [...this.refs.values()];
}
entries(): readonly ResolvedPrincipal[] {
return this.provenance;
}
}