forked from bai/curriculum-project-hub
feat(hub): 支持教师团队权限
This commit is contained in:
+39
-74
@@ -1,89 +1,64 @@
|
||||
/**
|
||||
* Permission gate — ADR-0004 in code, project-scope.
|
||||
* Permission compatibility exports.
|
||||
*
|
||||
* `triggerAgent` requires the `edit` capability (ADR-0004). `edit` is granted
|
||||
* by a `PermissionGrant` with role `edit` or `manage` (manage ⊇ edit, spec
|
||||
* `Role.can_mono`). This module checks that for the run's requester against
|
||||
* the project resource.
|
||||
*
|
||||
* Scope decision (project-wise): the resource is `project`, not
|
||||
* `project_group` — the run and lock scope to the project (ADR-0002), so the
|
||||
* capability is checked against the project. per-artifact permission is `OPEN`
|
||||
* pending a Courseware-side artifact id (ADR-0004 + ADR-0007 mapping).
|
||||
*
|
||||
* principal→user mapping is `OPEN` (ADR-0004 principal sub-typology). Here we
|
||||
* use the sender's Feishu open_id as the principal string literal — a pragmatic
|
||||
* choice for the first cut, not a spec decision. When a real principal model
|
||||
* lands, this is the single seam to change.
|
||||
*
|
||||
* settings.agentTrigger composition is `OPEN` (ADR-0004 separates role-derived
|
||||
* capabilities from settings policy knobs but doesn't pin the composition
|
||||
* rule). This gate implements the role half only; settings composition is
|
||||
* deferred and clearly marked.
|
||||
* ADR-0019 moves production authorization behind PrincipalResolver +
|
||||
* PermissionAuthorizer. The wrappers below preserve the old helper names for
|
||||
* tests and incremental callers while routing project trigger checks through
|
||||
* the new authorizer.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { PermissionRole } from "@prisma/client";
|
||||
import { createPermissionAuthorizer } from "./permissions/authorizer.js";
|
||||
|
||||
/// The roles that grant `edit` capability (edit itself + manage, by monotonicity).
|
||||
const EDIT_OR_HIGHER: ReadonlySet<PermissionRole> = new Set(["EDIT", "MANAGE"]);
|
||||
export { createPermissionAuthorizer, PrismaPermissionAuthorizer, roleGrantsEdit } from "./permissions/authorizer.js";
|
||||
export { PrismaPrincipalResolver, principalKey } from "./permissions/principals.js";
|
||||
export { syncExternalPrincipalMemberships } from "./permissions/externalSync.js";
|
||||
export type {
|
||||
AuthorizationAction,
|
||||
AuthorizationDecision,
|
||||
AuthorizationRequest,
|
||||
AuthorizationResource,
|
||||
PermissionAuthorizer,
|
||||
} from "./permissions/authorizer.js";
|
||||
export type {
|
||||
ActorInput,
|
||||
PrincipalRef,
|
||||
PrincipalResolution,
|
||||
PrincipalResolver,
|
||||
ResolvedPrincipal,
|
||||
} from "./permissions/principals.js";
|
||||
export type {
|
||||
ExternalPrincipalMembershipInput,
|
||||
ExternalPrincipalType,
|
||||
SyncExternalPrincipalMembershipsInput,
|
||||
SyncExternalPrincipalMembershipsResult,
|
||||
} from "./permissions/externalSync.js";
|
||||
|
||||
export interface PermissionResult {
|
||||
readonly allowed: boolean;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `principal` may trigger an agent run on `projectId`.
|
||||
*
|
||||
* Returns `{allowed, reason}`. On allow, the caller proceeds; on deny, the
|
||||
* caller replies with the reason. Never throws — a DB error is a deny with the
|
||||
* error as reason, so the run is not started on a broken state.
|
||||
*/
|
||||
export async function canTriggerAgent(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
principal: string,
|
||||
): Promise<PermissionResult> {
|
||||
try {
|
||||
// Look for an active (non-revoked) grant on the project resource for this
|
||||
// principal, with a role that grants edit or higher.
|
||||
const grant = await prisma.permissionGrant.findFirst({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: projectId,
|
||||
principal,
|
||||
role: { in: ["EDIT", "MANAGE"] },
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true, role: true },
|
||||
const decision = await createPermissionAuthorizer(prisma).can({
|
||||
actor: { feishuOpenId: principal },
|
||||
action: "agent.trigger",
|
||||
resource: { type: "PROJECT", id: projectId },
|
||||
});
|
||||
if (grant !== null) {
|
||||
return { allowed: true, reason: `granted by role ${grant.role}` };
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `no active edit+ grant for ${principal} on project ${projectId}`,
|
||||
};
|
||||
return { allowed: decision.allowed, reason: decision.reason };
|
||||
} catch (e) {
|
||||
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-role trigger gate (ADR-0017: roles are product config). Orthogonal to
|
||||
* {@link canTriggerAgent}: that decides "can trigger an agent at all"
|
||||
* (ADR-0004 triggerAgent capability); this decides "can trigger *which* role".
|
||||
* Two gates in series — both must pass.
|
||||
*
|
||||
* Semantics: if **no** `RoleTriggerGrant` row exists for `(projectId, roleId)`
|
||||
* (regardless of principal), the role is unconfigured on this project →
|
||||
* **allow** (back-compat: a project that hasn't configured per-role grants
|
||||
* doesn't get locked down). Once any grant exists for that role on the
|
||||
* project, only principals with an active grant may trigger it. "Grants exist
|
||||
* but this principal has none" → deny (admin explicitly restricted the role).
|
||||
*
|
||||
* `roleId` matches `RoleEntry.id` (the slash-command name). `principal` is the
|
||||
* same opaque string ADR-0004 uses (sender open_id here; principal
|
||||
* sub-typology OPEN).
|
||||
* Role-only legacy helper. New trigger code uses `role.trigger` on
|
||||
* PermissionAuthorizer so project edit+ and role grant composition happen in
|
||||
* one decision.
|
||||
*/
|
||||
export async function canTriggerRole(
|
||||
prisma: PrismaClient,
|
||||
@@ -92,10 +67,6 @@ export async function canTriggerRole(
|
||||
principal: string,
|
||||
): Promise<PermissionResult> {
|
||||
try {
|
||||
// Any grant record (active OR revoked) for this (project, role)? If none,
|
||||
// the role is unconfigured → allow (back-compat: no per-role restriction).
|
||||
// A revoked grant still counts as "configured" — revoking one person must
|
||||
// not silently reopen the role to everyone.
|
||||
const anyGrant = await prisma.roleTriggerGrant.findFirst({
|
||||
where: { projectId, roleId },
|
||||
select: { id: true },
|
||||
@@ -103,9 +74,8 @@ export async function canTriggerRole(
|
||||
if (anyGrant === null) {
|
||||
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
|
||||
}
|
||||
// Grants exist — this principal must hold one.
|
||||
const grant = await prisma.roleTriggerGrant.findFirst({
|
||||
where: { projectId, roleId, principal, revokedAt: null },
|
||||
where: { projectId, roleId, principalType: "USER", principalId: principal, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (grant !== null) {
|
||||
@@ -119,8 +89,3 @@ export async function canTriggerRole(
|
||||
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a role grants edit capability (edit or manage). Exported for tests.
|
||||
export function roleGrantsEdit(role: PermissionRole): boolean {
|
||||
return EDIT_OR_HIGHER.has(role);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user