/** * Permission gate — ADR-0004 in code, project-scope. * * `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. */ import type { PrismaClient } from "@prisma/client"; import type { PermissionRole } from "@prisma/client"; /// The roles that grant `edit` capability (edit itself + manage, by monotonicity). const EDIT_OR_HIGHER: ReadonlySet = new Set(["EDIT", "MANAGE"]); 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 { 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 }, }); 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}`, }; } 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). */ export async function canTriggerRole( prisma: PrismaClient, projectId: string, roleId: string, principal: string, ): Promise { 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 }, }); 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 }, select: { id: true }, }); if (grant !== null) { return { allowed: true, reason: `granted role ${roleId}` }; } return { allowed: false, reason: `no active ${roleId} grant for ${principal} on project ${projectId}`, }; } catch (e) { 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); }