diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 2753512bd51..e1810f755f1 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -34,7 +34,7 @@ These decisions close open questions from `SPEC.md` for V1. | Company model | Company is first-order; all business entities are company-scoped | | Board | Single human board operator per deployment | | Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting | -| Visibility | Full visibility to board and all agents in same company | +| Visibility | Company-scoped visibility: board + all in-company agents can see all work objects by default; public/private deployment flags affect external exposure only and do **not** imply project/issue privacy | | Communication | Tasks + comments only (no separate chat system) | | Task ownership | Single assignee; atomic checkout required for `in_progress` transition | | Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) | @@ -487,6 +487,59 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and | Report cost | yes | yes | | Set company budget | yes | no | | Set subordinate budget | yes | yes (manager subtree only) | +| Set work-object visibility (issue/project) | no | no (pro gate) | + +## 9.4 Permission Terminology and Default Visibility Rule + +Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface. + +The approved term set is: + +- **Agent profile visibility**: identity-level facts needed for delegation and governance (name, role, capabilities, reporting lines). +- **Agent config visibility**: adapter/runtime config metadata and secret-access policy. +- **Assignment/invocation permission**: who may modify or execute a task. +- **Work-object visibility**: who can read/write issues, comments, projects, and attachments. +- **Tool/secret policy**: what tools and secret-backed credentials an agent can use and what appears in logs. +- **Escalation authority**: where refusal/blocked decisions route (manager, then board). + +## 9.5 Core V1 Rule: what “private” means + +- A **private marker** on an agent profile (where represented) does **not** make company-visible work private. +- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default. +- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls. + +## 9.6 V1 vs Pro/Enterprise Controls (recommended target split) + +| Permission area | Free / V1 default | Pro / Enterprise | +|---|---|---| +| Company boundary | Hard boundary only (`company_id`) | Multi-company policy overlays (`membership`, `project`, and `task` scopes) | +| Simple roles | Board + agent roles with existing approval/budget gates | Additional role aliases + scoped approver roles | +| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces | +| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement | +| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes | +| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels | +| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation | +| Escalation | Escalate from agent to manager to board; board approval/budget gates remain authoritative | Escalation routing and SLA windows | + +## 9.7 Recommended first-slice implementation order + +1. Lock route-level checks for existing company boundaries, actor extraction, and approval/budget gates. +2. Treat profile privacy as external-facing signal only; do not use it to hide company-visible work objects. +3. Enforce assignment/invocation coupling (`assignee`/`agent` checks, checkout semantics, invocation checks). +4. Standardize read-path redaction for secrets and secret references, including logs and activity. +5. Standardize escalation paths (`blocked` and refusal) so non-board agents hand off by manager/board with immutable audit. + +## 9.8 Scoped Task Assignment Grants + +`tasks:assign` remains the broad assignment permission. Existing unscoped grants preserve compatibility and allow the principal to assign any visible company task within normal company-boundary checks. + +`tasks:assign_scope` is the constrained assignment permission. Its `principal_permission_grants.scope` JSON must include at least one recognized constraint: + +- Project scope: `projectId`, `projectIds`, or `allow: ["project:"]`. +- Target-agent allowlist: `agentId`, `agentIds`, `assigneeAgentId`, `assigneeAgentIds`, `targetAgentId`, `targetAgentIds`, or `allow: ["agent:"]`. +- Managed-subtree scope: `managerAgentId`, `managerAgentIds`, `managedSubtreeAgentId`, `managedSubtreeAgentIds`, `subtreeAgentId`, `subtreeAgentIds`, `subtreeRootAgentId`, `subtreeRootAgentIds`, or `allow: ["subtree:"]`. + +When multiple constraint families are present, assignment must satisfy all of them. Denials return `403` with a generic scope explanation and do not disclose details about hidden or unrelated resources. ## 10. API Contract (REST) diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts new file mode 100644 index 00000000000..eb598eb1114 --- /dev/null +++ b/server/src/__tests__/authorization-service.test.ts @@ -0,0 +1,343 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + companyMemberships, + createDb, + instanceUserRoles, + principalPermissionGrants, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { authorizationService } from "../services/authorization.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function createCompany(db: ReturnType, label: string) { + return db + .insert(companies) + .values({ + name: `Authorization ${label} ${randomUUID()}`, + issuePrefix: `AZ${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function createAgent( + db: ReturnType, + companyId: string, + input: { role?: string; reportsTo?: string | null; permissions?: Record } = {}, +) { + return db + .insert(agents) + .values({ + companyId, + name: `Agent ${randomUUID()}`, + role: input.role ?? "engineer", + reportsTo: input.reportsTo ?? null, + permissions: input.permissions ?? {}, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function createProject(db: ReturnType, companyId: string, label: string) { + return db + .insert(projects) + .values({ + companyId, + name: `Project ${label} ${randomUUID()}`, + }) + .returning() + .then((rows) => rows[0]!); +} + +async function grantAgentPermission( + db: ReturnType, + companyId: string, + agentId: string, + permissionKey: "tasks:assign" | "tasks:assign_scope", + scope: Record | null = null, +) { + await db.insert(companyMemberships).values({ + companyId, + principalType: "agent", + principalId: agentId, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values({ + companyId, + principalType: "agent", + principalId: agentId, + permissionKey, + scope, + grantedByUserId: null, + }); +} + +describeEmbeddedPostgres("authorization service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-authorization-service-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(instanceUserRoles); + await db.delete(agents); + await db.delete(projects); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("allows active user role grants and explains the grant source", async () => { + const company = await createCompany(db, "UserGrant"); + const userId = `user-${randomUUID()}`; + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + status: "active", + membershipRole: "operator", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: userId, + permissionKey: "tasks:assign", + grantedByUserId: "owner", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "board", userId, source: "session" }, + action: "tasks:assign", + resource: { type: "company", companyId: company.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_explicit_grant", + grant: { + principalType: "user", + principalId: userId, + permissionKey: "tasks:assign", + }, + }); + expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign"); + }); + + it("allows agent grants for agent configuration decisions", async () => { + const company = await createCompany(db, "AgentGrant"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + permissionKey: "agents:create", + grantedByUserId: null, + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "agent_config:read", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + }); + + expect(decision.allowed).toBe(true); + expect(decision.grant?.permissionKey).toBe("agents:create"); + }); + + it("denies cross-company agent decisions before grant evaluation", async () => { + const sourceCompany = await createCompany(db, "Source"); + const targetCompany = await createCompany(db, "Target"); + const actorAgent = await createAgent(db, sourceCompany.id); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_jwt" }, + action: "tasks:assign", + resource: { type: "company", companyId: targetCompany.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_company_boundary", + }); + expect(decision.explanation).toContain("Agent key cannot access another company"); + }); + + it("denies task assignment without a grant or legacy creator authority", async () => { + const company = await createCompany(db, "AssignmentDeny"); + const actorAgent = await createAgent(db, company.id, { role: "engineer" }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + status: "active", + membershipRole: "member", + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "company", companyId: company.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_missing_grant", + }); + expect(decision.explanation).toContain("Missing permission: tasks:assign"); + }); + + it("preserves legacy CEO agent creator authority", async () => { + const company = await createCompany(db, "Legacy"); + const actorAgent = await createAgent(db, company.id, { role: "ceo" }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" }, + action: "agents:create", + resource: { type: "company", companyId: company.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + reason: "allow_legacy_agent_creator", + }); + }); + + it("allows scoped assignment inside a granted project and denies other projects", async () => { + const company = await createCompany(db, "ProjectScope"); + const project = await createProject(db, company.id, "Allowed"); + const otherProject = await createProject(db, company.id, "Denied"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + projectIds: [project.id], + }); + + const allowed = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, projectId: project.id, assigneeAgentId: targetAgent.id }, + scope: { projectId: project.id, assigneeAgentId: targetAgent.id }, + }); + const denied = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, projectId: otherProject.id, assigneeAgentId: targetAgent.id }, + scope: { projectId: otherProject.id, assigneeAgentId: targetAgent.id }, + }); + + expect(allowed).toMatchObject({ + allowed: true, + grant: { permissionKey: "tasks:assign_scope" }, + }); + expect(denied).toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + expect(denied.explanation).toContain("does not cover the requested scope"); + }); + + it("allows scoped assignment to agents inside a managed subtree only", async () => { + const company = await createCompany(db, "SubtreeScope"); + const actorAgent = await createAgent(db, company.id); + const managerAgent = await createAgent(db, company.id); + const childAgent = await createAgent(db, company.id, { reportsTo: managerAgent.id }); + const grandchildAgent = await createAgent(db, company.id, { reportsTo: childAgent.id }); + const outsideAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + managedSubtreeAgentIds: [managerAgent.id], + }); + + const allowed = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: grandchildAgent.id }, + scope: { assigneeAgentId: grandchildAgent.id }, + }); + const denied = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: outsideAgent.id }, + scope: { assigneeAgentId: outsideAgent.id }, + }); + + expect(allowed.allowed).toBe(true); + expect(allowed.grant?.permissionKey).toBe("tasks:assign_scope"); + expect(denied).toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + }); + + it("allows scoped assignment to an explicit target-agent allowlist only", async () => { + const company = await createCompany(db, "AllowlistScope"); + const actorAgent = await createAgent(db, company.id); + const allowedTarget = await createAgent(db, company.id); + const deniedTarget = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", { + assigneeAgentIds: [allowedTarget.id], + }); + + const allowed = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: allowedTarget.id }, + scope: { assigneeAgentId: allowedTarget.id }, + }); + const denied = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: deniedTarget.id }, + scope: { assigneeAgentId: deniedTarget.id }, + }); + + expect(allowed.allowed).toBe(true); + expect(denied.allowed).toBe(false); + }); + + it("preserves unscoped tasks:assign compatibility for assignment decisions", async () => { + const company = await createCompany(db, "BroadAssign"); + const actorAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign"); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "tasks:assign", + resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id }, + scope: { assigneeAgentId: targetAgent.id }, + }); + + expect(decision).toMatchObject({ + allowed: true, + grant: { permissionKey: "tasks:assign" }, + }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 9b2b9da7963..43511895698 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1252,8 +1252,50 @@ export function issueRoutes( return Boolean((agent.permissions as Record).canCreateAgents); } - async function assertCanAssignTasks(req: Request, companyId: string) { + type TaskAssignmentAuthorizationScope = { + projectId?: string | null; + parentIssueId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + }; + + async function resolveAssignmentProjectId(input: { + companyId: string; + projectId: string | null | undefined; + parentIssueId?: string | null; + }) { + if (input.projectId !== undefined) return input.projectId; + if (!input.parentIssueId) return null; + const parent = await svc.getById(input.parentIssueId); + if (!parent || parent.companyId !== input.companyId) return null; + return parent.projectId ?? null; + } + + async function assertCanAssignTasks( + req: Request, + companyId: string, + assignmentScope?: TaskAssignmentAuthorizationScope, + ) { assertCompanyAccess(req, companyId); + const decide = (access as typeof access & { decide?: typeof access.decide }).decide; + if (typeof decide === "function") { + const decision = await decide({ + actor: req.actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId, + projectId: assignmentScope?.projectId ?? null, + parentIssueId: assignmentScope?.parentIssueId ?? null, + assigneeAgentId: assignmentScope?.assigneeAgentId ?? null, + assigneeUserId: assignmentScope?.assigneeUserId ?? null, + }, + scope: assignmentScope ?? null, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation); + } + if (req.actor.type === "board") { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign"); @@ -1284,6 +1326,16 @@ export function issueRoutes( companyId: string, assigneeAgentId: string, ) { + const decide = (access as typeof access & { decide?: typeof access.decide }).decide; + if (typeof decide === "function") { + const decision = await decide({ + actor: { type: "agent", agentId: actorAgentId, companyId }, + action: "tasks:manage_active_checkouts", + resource: { type: "issue", companyId, assigneeAgentId }, + }); + return decision.allowed; + } + const allowedByGrant = await access.hasPermission( companyId, "agent", @@ -3146,7 +3198,16 @@ export function issueRoutes( assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { - await assertCanAssignTasks(req, companyId); + await assertCanAssignTasks(req, companyId, { + projectId: await resolveAssignmentProjectId({ + companyId, + projectId: req.body.projectId, + parentIssueId: req.body.parentId, + }), + parentIssueId: req.body.parentId ?? null, + assigneeAgentId: req.body.assigneeAgentId ?? null, + assigneeUserId: req.body.assigneeUserId ?? null, + }); } await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId); @@ -3242,7 +3303,12 @@ export function issueRoutes( assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return; if (req.body.assigneeAgentId || req.body.assigneeUserId) { - await assertCanAssignTasks(req, parent.companyId); + await assertCanAssignTasks(req, parent.companyId, { + projectId: req.body.projectId ?? parent.projectId ?? null, + parentIssueId: parent.id, + assigneeAgentId: req.body.assigneeAgentId ?? null, + assigneeUserId: req.body.assigneeUserId ?? null, + }); } await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId); @@ -3631,7 +3697,22 @@ export function issueRoutes( if (assigneeWillChange && !transition.workflowControlledAssignment) { if (!isAgentReturningIssueToCreator) { - await assertCanAssignTasks(req, existing.companyId); + await assertCanAssignTasks(req, existing.companyId, { + projectId: await resolveAssignmentProjectId({ + companyId: existing.companyId, + projectId: updateFields.projectId === undefined + ? existing.projectId + : updateFields.projectId as string | null | undefined, + parentIssueId: (updateFields.parentId === undefined + ? existing.parentId + : updateFields.parentId) as string | null | undefined, + }), + parentIssueId: (updateFields.parentId === undefined + ? existing.parentId + : updateFields.parentId) as string | null | undefined, + assigneeAgentId: nextAssigneeAgentId, + assigneeUserId: nextAssigneeUserId, + }); } } @@ -4401,6 +4482,15 @@ export function issueRoutes( return; } + if (issue.assigneeAgentId !== req.body.agentId) { + await assertCanAssignTasks(req, issue.companyId, { + projectId: issue.projectId ?? null, + parentIssueId: issue.parentId ?? null, + assigneeAgentId: req.body.agentId, + assigneeUserId: null, + }); + } + const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); if (closedExecutionWorkspace) { respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); diff --git a/server/src/services/access.ts b/server/src/services/access.ts index faac70da407..b1b70ca3cbc 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -9,6 +9,7 @@ import { } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; import { conflict } from "../errors.js"; +import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js"; type MembershipRow = typeof companyMemberships.$inferSelect; type GrantInput = { @@ -24,6 +25,8 @@ type MemberArchiveInput = { }; export function accessService(db: Db) { + const authorization = authorizationService(db); + async function isInstanceAdmin(userId: string | null | undefined): Promise { if (!userId) return false; const row = await db @@ -58,21 +61,13 @@ export function accessService(db: Db) { principalId: string, permissionKey: PermissionKey, ): Promise { - const membership = await getMembership(companyId, principalType, principalId); - if (!membership || membership.status !== "active") return false; - const grant = await db - .select({ id: principalPermissionGrants.id }) - .from(principalPermissionGrants) - .where( - and( - eq(principalPermissionGrants.companyId, companyId), - eq(principalPermissionGrants.principalType, principalType), - eq(principalPermissionGrants.principalId, principalId), - eq(principalPermissionGrants.permissionKey, permissionKey), - ), - ) - .then((rows) => rows[0] ?? null); - return Boolean(grant); + return authorization.decidePrincipalGrant({ + companyId, + principalType, + principalId, + permissionKey, + action: permissionKey, + }).then((decision) => decision.allowed); } async function canUser( @@ -80,9 +75,20 @@ export function accessService(db: Db) { userId: string | null | undefined, permissionKey: PermissionKey, ): Promise { - if (!userId) return false; - if (await isInstanceAdmin(userId)) return true; - return hasPermission(companyId, "user", userId, permissionKey); + return authorization.decide({ + actor: { type: "board", userId }, + action: permissionKey, + resource: { type: "company", companyId }, + }).then((decision) => decision.allowed); + } + + async function decide(input: { + actor: AuthorizationActor; + action: Parameters[0]["action"]; + resource: AuthorizationResource; + scope?: Record | null; + }) { + return authorization.decide(input); } async function listMembers(companyId: string) { @@ -768,6 +774,7 @@ export function accessService(db: Db) { return { isInstanceAdmin, + decide, canUser, hasPermission, getMembership, diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts new file mode 100644 index 00000000000..6ccd1862692 --- /dev/null +++ b/server/src/services/authorization.ts @@ -0,0 +1,560 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + companyMemberships, + instanceUserRoles, + principalPermissionGrants, +} from "@paperclipai/db"; +import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; + +export type AuthorizationActor = + { + type: "board" | "agent" | "none"; + userId?: string | null; + companyIds?: string[]; + memberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>; + isInstanceAdmin?: boolean; + agentId?: string | null; + companyId?: string | null; + source?: + | "local_implicit" + | "session" + | "board_key" + | "agent_key" + | "agent_jwt" + | "cloud_tenant" + | "none"; + }; + +export type AuthorizationAction = + | PermissionKey + | "agent_config:read" + | "agent_config:update" + | "issue:mutate"; + +export type AuthorizationResource = + | { type: "company"; companyId: string } + | { type: "agent"; companyId: string; agentId?: string | null } + | { + type: "issue"; + companyId: string; + issueId?: string | null; + projectId?: string | null; + parentIssueId?: string | null; + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + status?: string | null; + }; + +export type AuthorizationDecision = { + allowed: boolean; + action: AuthorizationAction; + explanation: string; + reason: + | "allow_local_board" + | "allow_instance_admin" + | "allow_explicit_grant" + | "allow_legacy_agent_creator" + | "allow_self" + | "allow_company_agent" + | "allow_manager_chain" + | "deny_unauthenticated" + | "deny_company_boundary" + | "deny_missing_membership" + | "deny_missing_grant" + | "deny_scope" + | "deny_unsupported_action"; + grant?: { + principalType: PrincipalType; + principalId: string; + permissionKey: PermissionKey; + scope: Record | null; + }; +}; + +type PrincipalGrantDecision = AuthorizationDecision & { + grant?: NonNullable; +}; + +function companyIdForResource(resource: AuthorizationResource) { + return resource.companyId; +} + +function permissionForAction(action: AuthorizationAction): PermissionKey | null { + if (action === "agent_config:read" || action === "agent_config:update") return "agents:create"; + if (action === "issue:mutate") return null; + return action; +} + +function canCreateAgentsLegacy(agent: { role: string; permissions: Record | null | undefined }) { + if (agent.role === "ceo") return true; + if (!agent.permissions || typeof agent.permissions !== "object") return false; + return Boolean(agent.permissions.canCreateAgents); +} + +function scopeValueList(value: unknown): string[] { + if (typeof value === "string" && value.trim()) return [value.trim()]; + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()); +} + +function prefixedScopeValues(grantScope: Record, prefix: string) { + return scopeValueList(grantScope.allow) + .filter((rule) => rule.startsWith(prefix)) + .map((rule) => rule.slice(prefix.length)) + .filter((value) => value.length > 0); +} + +function scopeValuesForKeys(grantScope: Record, keys: string[]) { + return keys.flatMap((key) => scopeValueList(grantScope[key])); +} + +function scopeIncludesId(ids: string[], id: string | null | undefined) { + return Boolean(id && ids.includes(id)); +} + +async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) { + if (rootAgentId === targetAgentId) return true; + + const rows = await db + .select({ id: agents.id, reportsTo: agents.reportsTo }) + .from(agents) + .where(eq(agents.companyId, companyId)); + const agentsById = new Map(rows.map((agent) => [agent.id, agent])); + + let cursor: string | null = targetAgentId; + for (let depth = 0; cursor && depth < 50; depth += 1) { + const current = agentsById.get(cursor); + if (!current) return false; + if (current.reportsTo === rootAgentId) return true; + cursor = current.reportsTo; + } + return false; +} + +async function scopeAllows( + db: Db, + companyId: string, + grantScope: Record | null, + requestedScope: Record | null | undefined, + options: { requireStructuredScope?: boolean } = {}, +) { + if (!grantScope || Object.keys(grantScope).length === 0) return !options.requireStructuredScope; + if (!requestedScope) return false; + + const targetAssigneeAgentId = + typeof requestedScope.assigneeAgentId === "string" + ? requestedScope.assigneeAgentId + : typeof requestedScope.targetAgentId === "string" + ? requestedScope.targetAgentId + : null; + const requestedProjectId = typeof requestedScope.projectId === "string" ? requestedScope.projectId : null; + let constrained = false; + + const projectIds = [ + ...scopeValueList(grantScope.projectId), + ...scopeValueList(grantScope.projectIds), + ...prefixedScopeValues(grantScope, "project:"), + ]; + if (projectIds.length > 0) { + constrained = true; + if (!scopeIncludesId(projectIds, requestedProjectId)) return false; + } + + const targetAgentIds = [ + ...scopeValuesForKeys(grantScope, [ + "agentId", + "agentIds", + "assigneeAgentId", + "assigneeAgentIds", + "targetAgentId", + "targetAgentIds", + ]), + ...prefixedScopeValues(grantScope, "agent:"), + ]; + if (targetAgentIds.length > 0) { + constrained = true; + if (!scopeIncludesId(targetAgentIds, targetAssigneeAgentId)) return false; + } + + const subtreeRootAgentIds = [ + ...scopeValuesForKeys(grantScope, [ + "managerAgentId", + "managerAgentIds", + "managedSubtreeAgentId", + "managedSubtreeAgentIds", + "subtreeAgentId", + "subtreeAgentIds", + "subtreeRootAgentId", + "subtreeRootAgentIds", + ]), + ...prefixedScopeValues(grantScope, "subtree:"), + ]; + if (subtreeRootAgentIds.length > 0) { + constrained = true; + if (!targetAssigneeAgentId) return false; + let matchesSubtree = false; + for (const rootAgentId of subtreeRootAgentIds) { + if (await isAgentInSubtree(db, companyId, rootAgentId, targetAssigneeAgentId)) { + matchesSubtree = true; + break; + } + } + if (!matchesSubtree) return false; + } + + return constrained; +} + +function allow(input: Omit): AuthorizationDecision { + return { ...input, allowed: true }; +} + +function deny(input: Omit): AuthorizationDecision { + return { ...input, allowed: false }; +} + +export function authorizationService(db: Db) { + async function isInstanceAdmin(userId: string | null | undefined): Promise { + if (!userId) return false; + if ( + await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows[0] ?? null) + ) { + return true; + } + return false; + } + + async function getActiveMembership( + companyId: string, + principalType: PrincipalType, + principalId: string, + ) { + return db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, principalType), + eq(companyMemberships.principalId, principalId), + eq(companyMemberships.status, "active"), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function findGrant( + companyId: string, + principalType: PrincipalType, + principalId: string, + permissionKey: PermissionKey, + ) { + return db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, principalType), + eq(principalPermissionGrants.principalId, principalId), + eq(principalPermissionGrants.permissionKey, permissionKey), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function decidePrincipalGrant(input: { + companyId: string; + principalType: PrincipalType; + principalId: string; + action: AuthorizationAction; + permissionKey: PermissionKey; + scope?: Record | null; + }): Promise { + const membership = await getActiveMembership(input.companyId, input.principalType, input.principalId); + if (!membership) { + return deny({ + action: input.action, + reason: "deny_missing_membership", + explanation: `${input.principalType} principal ${input.principalId} is not an active member of company ${input.companyId}.`, + }); + } + + const grant = await findGrant(input.companyId, input.principalType, input.principalId, input.permissionKey); + if (!grant) { + return deny({ + action: input.action, + reason: "deny_missing_grant", + explanation: `Missing permission: ${input.permissionKey}.`, + }); + } + + if ( + !(await scopeAllows(db, input.companyId, grant.scope, input.scope, { + requireStructuredScope: input.permissionKey === "tasks:assign_scope", + })) + ) { + return deny({ + action: input.action, + reason: "deny_scope", + explanation: `Permission ${input.permissionKey} does not cover the requested scope.`, + grant: { + principalType: input.principalType, + principalId: input.principalId, + permissionKey: input.permissionKey, + scope: grant.scope ?? null, + }, + }); + } + + return allow({ + action: input.action, + reason: "allow_explicit_grant", + explanation: `Allowed by explicit grant ${input.permissionKey}.`, + grant: { + principalType: input.principalType, + principalId: input.principalId, + permissionKey: input.permissionKey, + scope: grant.scope ?? null, + }, + }); + } + + async function loadAgent(agentId: string) { + return db + .select({ + id: agents.id, + companyId: agents.companyId, + role: agents.role, + reportsTo: agents.reportsTo, + permissions: agents.permissions, + }) + .from(agents) + .where(eq(agents.id, agentId)) + .then((rows) => rows[0] ?? null); + } + + async function isManagerOf(companyId: string, managerAgentId: string, assigneeAgentId: string) { + const rows = await db + .select({ id: agents.id, reportsTo: agents.reportsTo }) + .from(agents) + .where(eq(agents.companyId, companyId)); + const agentsById = new Map(rows.map((agent) => [agent.id, agent])); + + let cursor: string | null = assigneeAgentId; + for (let depth = 0; cursor && depth < 50; depth += 1) { + const assignee = agentsById.get(cursor); + if (!assignee) return false; + if (assignee.reportsTo === managerAgentId) return true; + cursor = assignee.reportsTo; + } + return false; + } + + async function decide(input: { + actor: AuthorizationActor; + action: AuthorizationAction; + resource: AuthorizationResource; + scope?: Record | null; + }): Promise { + const permissionKey = permissionForAction(input.action); + const companyId = companyIdForResource(input.resource); + + async function decideWithTaskAssignmentGrants( + principalType: PrincipalType, + principalId: string, + ): Promise { + const broadDecision = await decidePrincipalGrant({ + companyId, + principalType, + principalId, + action: input.action, + permissionKey: "tasks:assign", + scope: input.scope, + }); + if (broadDecision.allowed || broadDecision.reason === "deny_missing_membership") return broadDecision; + const scopedDecision = await decidePrincipalGrant({ + companyId, + principalType, + principalId, + action: input.action, + permissionKey: "tasks:assign_scope", + scope: input.scope, + }); + if (scopedDecision.allowed || broadDecision.reason === "deny_missing_grant") return scopedDecision; + return broadDecision; + } + + if (input.actor.type === "none") { + return deny({ + action: input.action, + reason: "deny_unauthenticated", + explanation: "Authentication required.", + }); + } + + if (input.actor.type === "board") { + if (input.actor.source === "local_implicit") { + return allow({ + action: input.action, + reason: "allow_local_board", + explanation: "Allowed because the actor is the local implicit board.", + }); + } + if (input.actor.isInstanceAdmin || await isInstanceAdmin(input.actor.userId)) { + return allow({ + action: input.action, + reason: "allow_instance_admin", + explanation: "Allowed because the actor is an instance admin.", + }); + } + if (!input.actor.userId) { + return deny({ + action: input.action, + reason: "deny_unauthenticated", + explanation: "Board user id is required.", + }); + } + if (!permissionKey) { + return deny({ + action: input.action, + reason: "deny_unsupported_action", + explanation: `No board permission mapping exists for ${input.action}.`, + }); + } + if (input.action === "tasks:assign") { + return decideWithTaskAssignmentGrants("user", input.actor.userId); + } + return decidePrincipalGrant({ + companyId, + principalType: "user", + principalId: input.actor.userId, + action: input.action, + permissionKey, + scope: input.scope, + }); + } + + const actorAgentId = input.actor.agentId ?? null; + if (!actorAgentId) { + return deny({ + action: input.action, + reason: "deny_unauthenticated", + explanation: "Agent authentication required.", + }); + } + if (input.actor.companyId !== companyId) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Agent key cannot access another company.", + }); + } + + if (input.action === "issue:mutate") { + const resource = input.resource.type === "issue" ? input.resource : null; + if (resource?.assigneeAgentId === actorAgentId) { + return allow({ + action: input.action, + reason: "allow_self", + explanation: "Allowed because the actor owns the assigned issue.", + }); + } + if (!resource?.assigneeAgentId) { + return allow({ + action: input.action, + reason: "allow_company_agent", + explanation: "Allowed because the issue has no agent assignee.", + }); + } + } + if ( + input.action === "agent_config:update" && + input.resource.type === "agent" && + input.resource.agentId === actorAgentId + ) { + return allow({ + action: input.action, + reason: "allow_self", + explanation: "Allowed because the actor is updating its own agent configuration.", + }); + } + + let deniedGrantDecision: AuthorizationDecision | null = null; + if (input.action === "tasks:assign") { + const grantDecision = await decideWithTaskAssignmentGrants("agent", actorAgentId); + if (grantDecision.allowed) return grantDecision; + deniedGrantDecision = grantDecision; + } else if (permissionKey) { + const grantDecision = await decidePrincipalGrant({ + companyId, + principalType: "agent", + principalId: actorAgentId, + action: input.action, + permissionKey, + scope: input.scope, + }); + if (grantDecision.allowed) return grantDecision; + } + + const actorAgent = await loadAgent(actorAgentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + return deny({ + action: input.action, + reason: "deny_company_boundary", + explanation: "Actor agent was not found in the target company.", + }); + } + + if ( + (input.action === "agents:create" || + input.action === "agent_config:read" || + input.action === "agent_config:update" || + input.action === "tasks:assign" || + input.action === "tasks:manage_active_checkouts") && + canCreateAgentsLegacy(actorAgent) + ) { + return allow({ + action: input.action, + reason: "allow_legacy_agent_creator", + explanation: "Allowed by legacy agent creator authority.", + }); + } + + if (deniedGrantDecision) return deniedGrantDecision; + + if ( + input.action === "tasks:manage_active_checkouts" && + input.resource.type === "issue" && + input.resource.assigneeAgentId && + await isManagerOf(companyId, actorAgentId, input.resource.assigneeAgentId) + ) { + return allow({ + action: input.action, + reason: "allow_manager_chain", + explanation: "Allowed because the actor manages the issue assignee in the reporting chain.", + }); + } + + return deny({ + action: input.action, + reason: "deny_missing_grant", + explanation: permissionKey + ? `Missing permission: ${permissionKey}.` + : `No agent permission mapping exists for ${input.action}.`, + }); + } + + return { + decide, + decidePrincipalGrant, + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 2e7c6bcdaf5..a43fac00faa 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -44,6 +44,13 @@ export { sidebarBadgeService } from "./sidebar-badges.js"; export { sidebarPreferenceService } from "./sidebar-preferences.js"; export { inboxDismissalService } from "./inbox-dismissals.js"; export { accessService } from "./access.js"; +export { authorizationService } from "./authorization.js"; +export type { + AuthorizationAction, + AuthorizationActor, + AuthorizationDecision, + AuthorizationResource, +} from "./authorization.js"; export { boardAuthService } from "./board-auth.js"; export { instanceSettingsService } from "./instance-settings.js"; export { companyPortabilityService } from "./company-portability.js";