mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Enforce scoped task assignment grants
This commit is contained in:
1 parent
c91a062326
commit
c1e801c82b
6 files changed
+1083
-23
No files matched your search
@@ -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:<projectId>"]`.
|
||||
- Target-agent allowlist: `agentId`, `agentIds`, `assigneeAgentId`, `assigneeAgentIds`, `targetAgentId`, `targetAgentIds`, or `allow: ["agent:<agentId>"]`.
|
||||
- Managed-subtree scope: `managerAgentId`, `managerAgentIds`, `managedSubtreeAgentId`, `managedSubtreeAgentIds`, `subtreeAgentId`, `subtreeAgentIds`, `subtreeRootAgentId`, `subtreeRootAgentIds`, or `allow: ["subtree:<agentId>"]`.
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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<typeof createDb>, 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<typeof createDb>,
|
||||
companyId: string,
|
||||
input: { role?: string; reportsTo?: string | null; permissions?: Record<string, unknown> } = {},
|
||||
) {
|
||||
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<typeof createDb>, companyId: string, label: string) {
|
||||
return db
|
||||
.insert(projects)
|
||||
.values({
|
||||
companyId,
|
||||
name: `Project ${label} ${randomUUID()}`,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function grantAgentPermission(
|
||||
db: ReturnType<typeof createDb>,
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
permissionKey: "tasks:assign" | "tasks:assign_scope",
|
||||
scope: Record<string, unknown> | 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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1252,8 +1252,50 @@ export function issueRoutes(
|
||||
return Boolean((agent.permissions as Record<string, unknown>).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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
if (!userId) return false;
|
||||
const row = await db
|
||||
@@ -58,21 +61,13 @@ export function accessService(db: Db) {
|
||||
principalId: string,
|
||||
permissionKey: PermissionKey,
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<typeof authorization.decide>[0]["action"];
|
||||
resource: AuthorizationResource;
|
||||
scope?: Record<string, unknown> | 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,
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
};
|
||||
};
|
||||
|
||||
type PrincipalGrantDecision = AuthorizationDecision & {
|
||||
grant?: NonNullable<AuthorizationDecision["grant"]>;
|
||||
};
|
||||
|
||||
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<string, unknown> | 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<string, unknown>, 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<string, unknown>, 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<string, unknown> | null,
|
||||
requestedScope: Record<string, unknown> | 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, "allowed">): AuthorizationDecision {
|
||||
return { ...input, allowed: true };
|
||||
}
|
||||
|
||||
function deny(input: Omit<AuthorizationDecision, "allowed">): AuthorizationDecision {
|
||||
return { ...input, allowed: false };
|
||||
}
|
||||
|
||||
export function authorizationService(db: Db) {
|
||||
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
||||
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<string, unknown> | null;
|
||||
}): Promise<PrincipalGrantDecision> {
|
||||
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<string, unknown> | null;
|
||||
}): Promise<AuthorizationDecision> {
|
||||
const permissionKey = permissionForAction(input.action);
|
||||
const companyId = companyIdForResource(input.resource);
|
||||
|
||||
async function decideWithTaskAssignmentGrants(
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
): Promise<AuthorizationDecision> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user