feat(hub): 支持教师团队权限

This commit is contained in:
2026-07-08 15:42:53 +08:00
parent 3f486232a8
commit dfcc2d70f8
12 changed files with 1303 additions and 118 deletions
+253
View File
@@ -0,0 +1,253 @@
import type { PermissionResourceType, PermissionRole, PrismaClient, PrincipalType } from "@prisma/client";
import { PrismaPrincipalResolver, type ActorInput, type PrincipalRef, type PrincipalResolution, type PrincipalResolver } from "./principals.js";
export type AuthorizationAction =
| "project.read"
| "project.edit"
| "collaborator.manage"
| "agent.trigger"
| "agent.cancel"
| "role.trigger";
export interface AuthorizationResource {
readonly type: PermissionResourceType;
readonly id: string;
}
export interface AuthorizationRequest {
readonly actor: ActorInput;
readonly action: AuthorizationAction;
readonly resource: AuthorizationResource;
readonly roleId?: string | undefined;
}
export interface MatchedPermissionGrant {
readonly id: string;
readonly principal: PrincipalRef;
readonly role: PermissionRole;
}
export interface MatchedRoleTriggerGrant {
readonly id: string;
readonly principal: PrincipalRef;
}
export interface AuthorizationDecision {
readonly allowed: boolean;
readonly reason: string;
readonly action: AuthorizationAction;
readonly resource: AuthorizationResource;
readonly actor: ActorInput;
readonly actorUserId?: string | undefined;
readonly principals: readonly PrincipalRef[];
readonly requiredRole: PermissionRole;
readonly effectiveRole?: PermissionRole | undefined;
readonly matchedGrant?: MatchedPermissionGrant | undefined;
readonly matchedRoleGrant?: MatchedRoleTriggerGrant | undefined;
}
export interface PermissionAuthorizer {
can(req: AuthorizationRequest): Promise<AuthorizationDecision>;
}
export class PrismaPermissionAuthorizer implements PermissionAuthorizer {
constructor(
private readonly prisma: PrismaClient,
private readonly resolver: PrincipalResolver = new PrismaPrincipalResolver(prisma),
) {}
async can(req: AuthorizationRequest): Promise<AuthorizationDecision> {
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 threshold = await this.requiredRole(req);
if (threshold === "DISABLED") {
return this.decision(req, resolution, {
allowed: false,
reason: "permission setting disables this action",
requiredRole: defaultRequiredRole(req.action),
});
}
const grant = await this.bestGrant(req.resource, resolution.principals, threshold);
if (grant === undefined) {
return this.decision(req, resolution, {
allowed: false,
reason: `no active ${threshold}+ grant for resolved principals`,
requiredRole: threshold,
});
}
if (req.action !== "role.trigger") {
return this.decision(req, resolution, {
allowed: true,
reason: `granted by ${grant.principal.type}:${grant.principal.id} ${grant.role}`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
});
}
const roleGate = await this.roleTriggerGrant(req.resource, req.roleId!, resolution.principals);
if (roleGate.status === "open") {
return this.decision(req, resolution, {
allowed: true,
reason: `role ${req.roleId} unconfigured on project (open)`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
});
}
if (roleGate.grant !== undefined) {
return this.decision(req, resolution, {
allowed: true,
reason: `role ${req.roleId} granted by ${roleGate.grant.principal.type}:${roleGate.grant.principal.id}`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
matchedRoleGrant: roleGate.grant,
});
}
return this.decision(req, resolution, {
allowed: false,
reason: `no active role ${req.roleId} grant for resolved principals`,
requiredRole: threshold,
effectiveRole: grant.role,
matchedGrant: grant,
});
}
private async requiredRole(req: AuthorizationRequest): Promise<PermissionRole | "DISABLED"> {
let required = defaultRequiredRole(req.action);
if ((req.action === "agent.trigger" || req.action === "role.trigger") && req.resource.type === "PROJECT") {
const settings = await this.prisma.permissionSettings.findFirst({
where: { resourceType: req.resource.type, resourceId: req.resource.id },
select: { agentTrigger: true },
});
const policy = normalizePolicy(settings?.agentTrigger);
if (policy === "DISABLED") return "DISABLED";
if (policy === "MANAGE_ONLY") required = "MANAGE";
}
return required;
}
private async bestGrant(
resource: AuthorizationResource,
principals: readonly PrincipalRef[],
threshold: PermissionRole,
): Promise<MatchedPermissionGrant | undefined> {
const grants = await this.prisma.permissionGrant.findMany({
where: {
resourceType: resource.type,
resourceId: resource.id,
revokedAt: null,
OR: principalWhere(principals),
},
select: { id: true, principalType: true, principalId: true, role: true },
});
const sufficient = grants
.filter((grant) => roleRank(grant.role) >= roleRank(threshold))
.sort((a, b) => roleRank(b.role) - roleRank(a.role))[0];
if (sufficient === undefined) return undefined;
return {
id: sufficient.id,
principal: { type: sufficient.principalType, id: sufficient.principalId },
role: sufficient.role,
};
}
private async roleTriggerGrant(
resource: AuthorizationResource,
roleId: string,
principals: readonly PrincipalRef[],
): Promise<{ readonly status: "open" } | { readonly status: "configured"; readonly grant?: MatchedRoleTriggerGrant | undefined }> {
if (resource.type !== "PROJECT") {
return { status: "configured" };
}
const anyGrant = await this.prisma.roleTriggerGrant.findFirst({
where: { projectId: resource.id, roleId },
select: { id: true },
});
if (anyGrant === null) return { status: "open" };
const grant = await this.prisma.roleTriggerGrant.findFirst({
where: {
projectId: resource.id,
roleId,
revokedAt: null,
OR: principalWhere(principals),
},
select: { id: true, principalType: true, principalId: true },
});
if (grant === null) return { status: "configured" };
return {
status: "configured",
grant: {
id: grant.id,
principal: { type: grant.principalType, id: grant.principalId },
},
};
}
private decision(
req: AuthorizationRequest,
resolution: PrincipalResolution,
result: Omit<AuthorizationDecision, "action" | "resource" | "actor" | "actorUserId" | "principals">,
): AuthorizationDecision {
return {
action: req.action,
resource: req.resource,
actor: req.actor,
...(resolution.userId !== undefined ? { actorUserId: resolution.userId } : {}),
principals: resolution.principals,
...result,
};
}
}
export function createPermissionAuthorizer(
prisma: PrismaClient,
resolver?: PrincipalResolver,
): PermissionAuthorizer {
return new PrismaPermissionAuthorizer(prisma, resolver);
}
export function roleGrantsEdit(role: PermissionRole): boolean {
return roleRank(role) >= roleRank("EDIT");
}
export function roleRank(role: PermissionRole): number {
switch (role) {
case "READ": return 1;
case "EDIT": return 2;
case "MANAGE": return 3;
}
}
function defaultRequiredRole(action: AuthorizationAction): PermissionRole {
switch (action) {
case "project.read": return "READ";
case "project.edit": return "EDIT";
case "agent.trigger": return "EDIT";
case "role.trigger": return "EDIT";
case "collaborator.manage": return "MANAGE";
case "agent.cancel": return "MANAGE";
}
}
function normalizePolicy(policy: string | undefined): "ROLE" | "MANAGE_ONLY" | "DISABLED" {
if (policy === undefined || policy.trim() === "") return "ROLE";
const normalized = policy.trim().toUpperCase();
if (normalized === "DISABLED" || normalized === "DENY" || normalized === "OFF") return "DISABLED";
if (normalized === "MANAGE_ONLY") return "MANAGE_ONLY";
return "ROLE";
}
function principalWhere(principals: readonly PrincipalRef[]): Array<{ principalType: PrincipalType; principalId: string }> {
return principals.map((principal) => ({
principalType: principal.type,
principalId: principal.id,
}));
}
+108
View File
@@ -0,0 +1,108 @@
import type { PrismaClient, PrincipalType } from "@prisma/client";
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 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;
for (const item of input.memberships) {
assertExternalPrincipalType(item.principalType);
const user = await prisma.user.upsert({
where: { feishuOpenId: item.feishuOpenId },
update: { displayName: item.displayName },
create: {
feishuOpenId: item.feishuOpenId,
displayName: item.displayName,
platformRoles: { create: { role: "TEACHER" } },
},
});
incomingKeys.add(externalMembershipKey(user.id, item.principalType, item.principalId));
const existing = await prisma.externalPrincipalMembership.findFirst({
where: {
userId: user.id,
principalType: item.principalType,
principalId: item.principalId,
source: input.source,
revokedAt: null,
},
select: { id: true },
});
if (existing === null) {
await prisma.externalPrincipalMembership.create({
data: {
userId: user.id,
principalType: item.principalType,
principalId: item.principalId,
source: input.source,
syncedAt,
},
});
} else {
await prisma.externalPrincipalMembership.update({
where: { id: existing.id },
data: { syncedAt },
});
}
synced++;
}
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 },
});
const revokeIds = active
.filter((membership) => !incomingKeys.has(externalMembershipKey(
membership.userId,
membership.principalType,
membership.principalId,
)))
.map((membership) => membership.id);
if (revokeIds.length > 0) {
const result = await prisma.externalPrincipalMembership.updateMany({
where: { id: { in: revokeIds } },
data: { revokedAt: syncedAt },
});
revoked = result.count;
}
}
return { synced, revoked };
}
function externalMembershipKey(userId: string, type: PrincipalType, id: string): string {
return `${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}`);
}
}
+135
View File
@@ -0,0 +1,135 @@
import type { PrismaClient, PrincipalType } from "@prisma/client";
export interface PrincipalRef {
readonly type: PrincipalType;
readonly id: string;
}
export interface ActorInput {
readonly feishuOpenId: string;
readonly chatId?: string | undefined;
}
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 userId?: string | undefined;
readonly principals: readonly PrincipalRef[];
readonly resolved: readonly ResolvedPrincipal[];
}
export interface PrincipalResolver {
resolveActor(actor: ActorInput): Promise<PrincipalResolution>;
}
export class PrismaPrincipalResolver implements PrincipalResolver {
constructor(private readonly prisma: PrismaClient) {}
async resolveActor(actor: ActorInput): Promise<PrincipalResolution> {
const resolved = new PrincipalCollector();
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
if (actor.chatId !== undefined && actor.chatId !== "") {
resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat");
}
const user = await this.prisma.user.findUnique({
where: { feishuOpenId: actor.feishuOpenId },
select: { id: true },
});
if (user !== null) {
const memberships = await this.prisma.teamMembership.findMany({
where: { userId: user.id, revokedAt: null, team: { 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 },
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: { 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,
...(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;
}
}