import { assertProjectPolicyProjectId, normalizePolicySubject, normalizeProjectPermission, normalizeProjectPolicySnapshot, ProjectPolicyUnavailableError, type ProjectPermission, type ProjectPolicyDecision, type ProjectPolicyDecisionWithFence, type ProjectPolicyRequest, type ProjectRole, type StaticProjectPermission, } from '../domain/projectPolicy'; import type { ProjectPolicyRepository } from '../ports/projectPolicyRepository'; const READ_ONLY_PERMISSIONS = new Set([ 'project.read', 'task.read', 'run.read', 'artifact.read', ]); const OPERATOR_PERMISSIONS = new Set([ ...READ_ONLY_PERMISSIONS, 'task.create', 'task.update', 'run.start', 'run.stop', 'run.retry', 'secret.use', ]); const ADMIN_EXCLUDED_PERMISSIONS = new Set([ 'project.manage', ]); const AGENT_APPROVAL_PERMISSIONS = new Set([ 'project.manage', 'task.create', 'task.update', 'task.delete', 'run.start', 'run.stop', 'run.retry', 'secret.use', 'secret.manage', 'worker.manage', 'policy.manage', 'approval.decide', ]); function decision( effect: ProjectPolicyDecision['effect'], reason: string, ): Readonly { return Object.freeze({ effect, reasons: Object.freeze([reason]) }); } function roleAllows(role: ProjectRole, permission: ProjectPermission): boolean { if (role === 'owner') return true; if (role === 'admin') { return ( permission.startsWith('tool.call:') || !ADMIN_EXCLUDED_PERMISSIONS.has(permission as StaticProjectPermission) ); } if (role === 'operator') { return ( permission.startsWith('tool.call:') || OPERATOR_PERMISSIONS.has(permission) ); } return READ_ONLY_PERMISSIONS.has(permission); } function agentRequiresApproval(permission: ProjectPermission): boolean { return ( permission.startsWith('tool.call:') || AGENT_APPROVAL_PERMISSIONS.has(permission) ); } export class ProjectPolicyEngine { constructor(private readonly repository: ProjectPolicyRepository) {} async decide( request: ProjectPolicyRequest, ): Promise> { return (await this.decideWithFence(request)).decision; } async decideWithFence( request: ProjectPolicyRequest, ): Promise> { if (!request || typeof request !== 'object' || Array.isArray(request)) { throw new TypeError('Project policy request must be an object'); } const requestKeys = Object.keys(request).sort(); if ( requestKeys.length !== 3 || requestKeys[0] !== 'permission' || requestKeys[1] !== 'projectId' || requestKeys[2] !== 'subject' ) { throw new TypeError('Project policy request shape is invalid'); } const subject = normalizePolicySubject(request.subject); assertProjectPolicyProjectId(request.projectId); const permission = normalizeProjectPermission(request.permission); let resolved; try { resolved = await this.repository.resolve(request.projectId, subject); } catch { throw new ProjectPolicyUnavailableError(); } if (!resolved) { return Object.freeze({ decision: decision('deny', 'project_not_found'), fence: null, }); } let snapshot; try { snapshot = normalizeProjectPolicySnapshot(resolved); } catch { throw new ProjectPolicyUnavailableError(); } if (snapshot.project.id !== request.projectId) { throw new ProjectPolicyUnavailableError(); } const fence = Object.freeze({ projectVersion: snapshot.project.version, bindingVersion: snapshot.binding?.version ?? null, }); if (!snapshot.binding || snapshot.binding.state === 'revoked') { return Object.freeze({ decision: decision('deny', 'subject_unbound'), fence, }); } if ( snapshot.binding.subject.type !== subject.type || snapshot.binding.subject.id !== subject.id ) { throw new ProjectPolicyUnavailableError(); } if ( snapshot.project.status === 'archived' && !READ_ONLY_PERMISSIONS.has(permission) ) { return Object.freeze({ decision: decision('deny', 'project_archived'), fence, }); } if (!roleAllows(snapshot.binding.role!, permission)) { return Object.freeze({ decision: decision('deny', 'permission_missing'), fence, }); } if (subject.type === 'agent' && agentRequiresApproval(permission)) { return Object.freeze({ decision: decision( 'require_approval', 'agent_action_requires_approval', ), fence, }); } return Object.freeze({ decision: decision('allow', 'role_grant'), fence, }); } }