Enforce scoped task assignment grants

This commit is contained in:
Dotta committed 2026-05-22 01:51:03 +00:00
1 parent c91a062326
commit c1e801c82b
6 files changed
+1083 -23

No files matched your search

+54 -1
View File
@@ -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" },
});
});
});
+94 -4
View File
@@ -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);
+25 -18
View File
@@ -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,
+560
View File
@@ -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,
};
}
+7
View File
@@ -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";