Honor protected assignment policies

This commit is contained in:
Dotta committed 2026-05-22 01:51:03 +00:00
1 parent 181574d66f
commit 26baa20b0b
9 files changed
+450 -17

No files matched your search

@@ -218,6 +218,93 @@ describeEmbeddedPostgres("authorization service", () => {
expect(decision.explanation).toContain("simple mode");
});
it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => {
const company = await createCompany(db, "ProtectedAssignment");
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
const targetAgent = await createAgent(db, company.id, {
role: "engineer",
permissions: {
authorizationPolicy: {
assignmentPolicy: {
mode: "protected",
protectedAgentRequiresApproval: true,
},
protectedAgent: {
requiresApproval: true,
approvalReason: "Production deployment authority",
},
managedBy: "paperclip-ee-permissions",
},
},
});
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: false,
reason: "deny_policy_restricted",
});
expect(decision.explanation).toContain("requires approval");
});
it("requires an explicit grant before assigning to a private target agent", async () => {
const company = await createCompany(db, "PrivateAssignment");
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
const targetAgent = await createAgent(db, company.id, {
role: "engineer",
permissions: {
authorizationPolicy: {
agentVisibility: {
mode: "private",
hiddenFromDefaultDirectory: true,
},
assignmentPolicy: {
mode: "company_default",
protectedAgentRequiresApproval: false,
},
protectedAgent: {
requiresApproval: false,
},
managedBy: "paperclip-ee-permissions",
},
},
});
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: targetAgent.id },
scope: { assigneeAgentId: targetAgent.id },
});
await grantAgentPermission(db, company.id, actorAgent.id, "tasks:assign_scope", {
assigneeAgentId: targetAgent.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: targetAgent.id },
scope: { assigneeAgentId: targetAgent.id },
});
expect(denied).toMatchObject({
allowed: false,
reason: "deny_policy_restricted",
});
expect(denied.explanation).toContain("private");
expect(allowed).toMatchObject({
allowed: true,
reason: "allow_explicit_grant",
grant: { permissionKey: "tasks:assign_scope" },
});
});
it("allows simple-mode task assignment for active same-company board operators without explicit grants", async () => {
const company = await createCompany(db, "BoardAssignmentDefault");
const userId = `user-${randomUUID()}`;
@@ -274,6 +274,7 @@ describe("agent issue mutation checkout ownership", () => {
vi.doUnmock("../middleware/index.js");
registerRouteMocks();
vi.clearAllMocks();
delete (mockAccessService as any).decide;
mockAccessService.canUser.mockReset();
mockAccessService.hasPermission.mockReset();
mockAgentService.getById.mockReset();
@@ -828,4 +829,37 @@ describe("agent issue mutation checkout ownership", () => {
}),
);
});
it("uses the authorization decision path for assignment changes", async () => {
const decide = vi.fn(async () => ({
allowed: false,
action: "tasks:assign",
reason: "deny_policy_restricted",
explanation: "Target agent requires approval before task assignment.",
}));
(mockAccessService as any).decide = decide;
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockAgentService.resolveByReference.mockResolvedValue({
ambiguous: false,
agent: makeAgent(peerAgentId),
});
const app = await createApp(ownerActor());
const res = await request(app)
.patch(`/api/issues/${issueId}`)
.send({ assigneeAgentId: peerAgentId });
expect(res.status).toBe(403);
expect(res.body.error).toContain("requires approval");
expect(decide).toHaveBeenCalledWith(expect.objectContaining({
action: "tasks:assign",
resource: expect.objectContaining({
type: "issue",
companyId,
issueId,
assigneeAgentId: peerAgentId,
}),
}));
expect(mockIssueService.update).not.toHaveBeenCalled();
});
});
@@ -17,6 +17,7 @@ import {
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const pluginId = "plugin-record-id";
function createEventBusStub() {
return {
@@ -78,7 +79,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
})
.returning()
.then((rows) => rows[0]!);
const services = buildHostServices(db, "plugin-record-id", "paperclip-ee", createEventBusStub());
const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub());
await expect(
services.authorization.setGrants({
@@ -96,7 +97,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
it("redacts invite token hashes and sensitive defaults from plugin invite reads", async () => {
const company = await createCompany(db, "PAZ");
const services = buildHostServices(db, "plugin-record-id", "paperclip-ee", createEventBusStub());
const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub());
const created = await services.access.createInvite({
companyId: company.id,
@@ -123,7 +124,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
it("filters authorization audit entries by allow or deny decision details", async () => {
const company = await createCompany(db, "PAU");
const services = buildHostServices(db, "plugin-record-id", "paperclip-ee", createEventBusStub());
const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub());
await db.insert(activityLog).values([
{
companyId: company.id,
@@ -145,16 +146,18 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
},
]);
const allowed = await services.authorization.searchAudit({
companyId: company.id,
action: "authorization.assignment_preview",
decision: "allow",
});
const denied = await services.authorization.searchAudit({
companyId: company.id,
action: "authorization.assignment_preview",
decision: "deny",
});
const [allowed, denied] = await Promise.all([
services.authorization.searchAudit({
companyId: company.id,
action: "authorization.assignment_preview",
decision: "allow",
}),
services.authorization.searchAudit({
companyId: company.id,
action: "authorization.assignment_preview",
decision: "deny",
}),
]);
expect(allowed).toHaveLength(1);
expect(allowed[0]!.entityId).toBe("issue-1");
@@ -163,4 +166,79 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
expect(denied[0]!.entityId).toBe("issue-2");
services.dispose();
});
it("uses persisted agent policy for plugin assignment preview and explanation", async () => {
const company = await createCompany(db, "PAP");
const [actorAgent, targetAgent] = await db
.insert(agents)
.values([
{
companyId: company.id,
name: "Actor agent",
role: "engineer",
adapterType: "process",
adapterConfig: {},
permissions: {},
},
{
companyId: company.id,
name: "Protected target",
role: "engineer",
adapterType: "process",
adapterConfig: {},
permissions: {},
},
])
.returning();
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "agent",
principalId: actorAgent!.id,
status: "active",
membershipRole: "member",
});
const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub());
const updatedPolicy = await services.authorization.updatePolicy({
companyId: company.id,
resourceType: "agent",
resourceId: targetAgent!.id,
policy: {
assignmentPolicy: {
mode: "protected",
protectedAgentRequiresApproval: true,
},
protectedAgent: {
requiresApproval: true,
approvalReason: "Needs board approval",
},
managedBy: "paperclip-ee-permissions",
},
});
const input = {
companyId: company.id,
actor: {
type: "agent" as const,
agentId: actorAgent!.id,
companyId: company.id,
source: "agent_key" as const,
},
target: { assigneeAgentId: targetAgent!.id },
};
const [policy, preview, explanation] = await Promise.all([
Promise.resolve(updatedPolicy),
services.authorization.previewAssignment(input),
services.authorization.explainAssignment(input),
]);
expect(policy.policy).toMatchObject({
protectedAgent: { requiresApproval: true },
});
expect(preview).toMatchObject({
allowed: false,
reason: "deny_policy_restricted",
});
expect(explanation).toMatchObject(preview);
services.dispose();
});
});
@@ -613,6 +613,28 @@ describe.sequential("plugin tool and bridge authz", () => {
expect(call).not.toHaveBeenCalled();
});
it("forwards authorized bridge company scope to the plugin worker", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
const { app } = await createApp(boardActor(), {}, {
bridgeDeps: {
workerManager: { call },
},
});
const res = await request(app)
.post(`/api/plugins/${pluginId}/data/health`)
.send({ companyId: companyA, params: { view: "compact" } });
expect(res.status).toBe(200);
expect(call).toHaveBeenCalledWith(pluginId, "getData", {
key: "health",
companyId: companyA,
params: { view: "compact" },
renderEnvironment: null,
});
});
it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => {
readyPlugin();
const call = vi.fn().mockResolvedValue({ ok: true });
+4
View File
@@ -1253,6 +1253,7 @@ export function issueRoutes(
}
type TaskAssignmentAuthorizationScope = {
issueId?: string | null;
projectId?: string | null;
parentIssueId?: string | null;
assigneeAgentId?: string | null;
@@ -1285,6 +1286,7 @@ export function issueRoutes(
resource: {
type: "issue",
companyId,
issueId: assignmentScope?.issueId ?? null,
projectId: assignmentScope?.projectId ?? null,
parentIssueId: assignmentScope?.parentIssueId ?? null,
assigneeAgentId: assignmentScope?.assigneeAgentId ?? null,
@@ -3698,6 +3700,7 @@ export function issueRoutes(
if (assigneeWillChange && !transition.workflowControlledAssignment) {
if (!isAgentReturningIssueToCreator) {
await assertCanAssignTasks(req, existing.companyId, {
issueId: existing.id,
projectId: await resolveAssignmentProjectId({
companyId: existing.companyId,
projectId: updateFields.projectId === undefined
@@ -4484,6 +4487,7 @@ export function issueRoutes(
if (issue.assigneeAgentId !== req.body.agentId) {
await assertCanAssignTasks(req, issue.companyId, {
issueId: issue.id,
projectId: issue.projectId ?? null,
parentIssueId: issue.parentId ?? null,
assigneeAgentId: req.body.agentId,
+2
View File
@@ -18,7 +18,9 @@ export function normalizeAgentPermissions(
}
const record = permissions as Record<string, unknown>;
const preserved = { ...record };
return {
...preserved,
canCreateAgents:
typeof record.canCreateAgents === "boolean"
? record.canCreateAgents
+1 -1
View File
@@ -554,7 +554,7 @@ export function agentService(db: Db) {
const updated = await db
.update(agents)
.set({
permissions: normalizeAgentPermissions(permissions, existing.role),
permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role),
updatedAt: new Date(),
})
.where(eq(agents.id, id))
+205 -3
View File
@@ -4,7 +4,9 @@ import {
agents,
companyMemberships,
instanceUserRoles,
issues,
principalPermissionGrants,
projects,
} from "@paperclipai/db";
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
@@ -64,6 +66,7 @@ export type AuthorizationDecision = {
| "deny_company_boundary"
| "deny_missing_membership"
| "deny_missing_grant"
| "deny_policy_restricted"
| "deny_scope"
| "deny_unsupported_action";
grant?: {
@@ -121,6 +124,104 @@ function isSimpleAssignableAgentStatus(status: string | null | undefined) {
return status !== "pending_approval" && status !== "terminated";
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function objectIsEmpty(value: Record<string, unknown>) {
return Object.keys(value).length === 0;
}
function readPolicyObject(container: unknown, key: string): Record<string, unknown> | null {
if (!isPlainRecord(container)) return null;
const value = container[key];
return isPlainRecord(value) ? value : null;
}
function readString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readBoolean(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
type AssignmentPolicyEffect =
| { kind: "none" }
| { kind: "restricted"; explanation: string }
| { kind: "requires_approval"; explanation: string }
| { kind: "unknown"; explanation: string };
function evaluateAuthorizationPolicyForAssignment(
policy: Record<string, unknown> | null | undefined,
label: string,
): AssignmentPolicyEffect {
if (!policy || objectIsEmpty(policy)) return { kind: "none" };
const agentVisibility = readPolicyObject(policy, "agentVisibility");
const assignmentPolicy = readPolicyObject(policy, "assignmentPolicy");
const protectedAgent = readPolicyObject(policy, "protectedAgent");
const knownTopLevelKeys = new Set([
"agentVisibility",
"assignmentPolicy",
"protectedAgent",
"managedBy",
]);
const hasUnknownTopLevelKey = Object.keys(policy).some((key) => !knownTopLevelKeys.has(key));
const hasKnownPolicySection = Boolean(agentVisibility || assignmentPolicy || protectedAgent);
if (hasUnknownTopLevelKey || !hasKnownPolicySection) {
return {
kind: "unknown",
explanation: `${label} has authorization policy data that core cannot evaluate for task assignment.`,
};
}
const visibilityMode = readString(agentVisibility?.mode);
if (visibilityMode && visibilityMode !== "discoverable" && visibilityMode !== "private") {
return {
kind: "unknown",
explanation: `${label} has an unsupported agent visibility policy mode.`,
};
}
const assignmentMode = readString(assignmentPolicy?.mode);
if (assignmentMode && assignmentMode !== "company_default" && assignmentMode !== "protected") {
return {
kind: "unknown",
explanation: `${label} has an unsupported assignment policy mode.`,
};
}
const requiresApproval =
readBoolean(protectedAgent?.requiresApproval) === true ||
readBoolean(assignmentPolicy?.protectedAgentRequiresApproval) === true;
if (requiresApproval) {
return {
kind: "requires_approval",
explanation: `${label} requires approval before task assignment.`,
};
}
if (
visibilityMode === "private" ||
readBoolean(agentVisibility?.hiddenFromDefaultDirectory) === true
) {
return {
kind: "restricted",
explanation: `${label} is private and cannot use simple company-wide task assignment.`,
};
}
if (assignmentMode === "protected") {
return {
kind: "restricted",
explanation: `${label} is protected and requires an explicit assignment grant.`,
};
}
return { kind: "none" };
}
async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) {
if (rootAgentId === targetAgentId) return true;
@@ -348,6 +449,24 @@ export function authorizationService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function loadProjectAuthorizationPolicy(companyId: string, projectId: string) {
const row = await db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.companyId, companyId)))
.then((rows) => rows[0] ?? null);
return readPolicyObject(row?.executionWorkspacePolicy, "authorizationPolicy");
}
async function loadIssueAuthorizationPolicy(companyId: string, issueId: string) {
const row = await db
.select({ executionPolicy: issues.executionPolicy })
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
.then((rows) => rows[0] ?? null);
return readPolicyObject(row?.executionPolicy, "authorizationPolicy");
}
async function assignmentTargetIsInCompany(resource: AuthorizationResource) {
if (resource.type !== "issue") return true;
if (resource.assigneeAgentId) {
@@ -364,6 +483,52 @@ export function authorizationService(db: Db) {
return true;
}
async function assignmentPolicyEffect(resource: AuthorizationResource): Promise<AssignmentPolicyEffect> {
if (resource.type !== "issue") return { kind: "none" };
const checks: Array<Promise<AssignmentPolicyEffect>> = [];
if (resource.assigneeAgentId) {
checks.push(
loadAgent(resource.assigneeAgentId).then((agent) =>
evaluateAuthorizationPolicyForAssignment(
readPolicyObject(agent?.permissions, "authorizationPolicy"),
"Target agent",
),
),
);
}
if (resource.projectId) {
checks.push(
loadProjectAuthorizationPolicy(resource.companyId, resource.projectId).then((policy) =>
evaluateAuthorizationPolicyForAssignment(policy, "Target project"),
),
);
}
if (resource.issueId) {
checks.push(
loadIssueAuthorizationPolicy(resource.companyId, resource.issueId).then((policy) =>
evaluateAuthorizationPolicyForAssignment(policy, "Target issue"),
),
);
}
if (resource.parentIssueId && resource.parentIssueId !== resource.issueId) {
checks.push(
loadIssueAuthorizationPolicy(resource.companyId, resource.parentIssueId).then((policy) =>
evaluateAuthorizationPolicyForAssignment(policy, "Parent issue"),
),
);
}
if (checks.length === 0) return { kind: "none" };
const effects = await Promise.all(checks);
return (
effects.find((effect) => effect.kind === "unknown") ??
effects.find((effect) => effect.kind === "requires_approval") ??
effects.find((effect) => effect.kind === "restricted") ??
{ kind: "none" }
);
}
async function isManagerOf(companyId: string, managerAgentId: string, assigneeAgentId: string) {
const rows = await db
.select({ id: agents.id, reportsTo: agents.reportsTo })
@@ -415,6 +580,28 @@ export function authorizationService(db: Db) {
return broadDecision;
}
async function denyForAssignmentPolicyIfNeeded(
policyEffect: AssignmentPolicyEffect,
): Promise<AuthorizationDecision | null> {
if (policyEffect.kind === "none" || policyEffect.kind === "restricted") return null;
return deny({
action: input.action,
reason: "deny_policy_restricted",
explanation: policyEffect.explanation,
});
}
function denyRestrictedAssignmentPolicy(policyEffect: AssignmentPolicyEffect): AuthorizationDecision {
return deny({
action: input.action,
reason: "deny_policy_restricted",
explanation:
policyEffect.kind === "restricted"
? policyEffect.explanation
: "Restrictive authorization policy blocks simple company-wide task assignment.",
});
}
if (input.actor.type === "none") {
return deny({
action: input.action,
@@ -453,15 +640,18 @@ export function authorizationService(db: Db) {
explanation: "Task assignment target agent is not active in the target company.",
});
}
const policyEffect = await assignmentPolicyEffect(input.resource);
const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect);
if (policyDeny) return policyDeny;
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
if (membership && membership.membershipRole !== "viewer") {
if (policyEffect.kind === "none" && membership && membership.membershipRole !== "viewer") {
return allow({
action: input.action,
reason: "allow_simple_company_member",
explanation: "Allowed by simple mode company-wide task assignment default.",
});
}
if (!input.actor.memberships && input.actor.companyIds?.includes(companyId)) {
if (policyEffect.kind === "none" && !input.actor.memberships && input.actor.companyIds?.includes(companyId)) {
return allow({
action: input.action,
reason: "allow_simple_company_member",
@@ -477,7 +667,11 @@ export function authorizationService(db: Db) {
});
}
if (input.action === "tasks:assign") {
return decideWithTaskAssignmentGrants("user", input.actor.userId);
const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId);
if (grantDecision.allowed) return grantDecision;
const policyEffect = await assignmentPolicyEffect(input.resource);
if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect);
return grantDecision;
}
return decidePrincipalGrant({
companyId,
@@ -529,6 +723,14 @@ export function authorizationService(db: Db) {
explanation: "Task assignment target agent is not active in the target company.",
});
}
const policyEffect = await assignmentPolicyEffect(input.resource);
const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect);
if (policyDeny) return policyDeny;
if (policyEffect.kind === "restricted") {
const grantDecision = await decideWithTaskAssignmentGrants("agent", actorAgentId);
if (grantDecision.allowed) return grantDecision;
return denyRestrictedAssignmentPolicy(policyEffect);
}
return allow({
action: input.action,
reason: "allow_simple_company_member",
@@ -2443,7 +2443,9 @@ export function buildHostServices(
action: "tasks:assign",
resource: { type: "issue", companyId, ...params.target },
scope: {
issueId: params.target.issueId ?? null,
projectId: params.target.projectId ?? null,
parentIssueId: params.target.parentIssueId ?? null,
assigneeAgentId: params.target.assigneeAgentId ?? null,
assigneeUserId: params.target.assigneeUserId ?? null,
},
@@ -2457,7 +2459,9 @@ export function buildHostServices(
action: "tasks:assign",
resource: { type: "issue", companyId, ...params.target },
scope: {
issueId: params.target.issueId ?? null,
projectId: params.target.projectId ?? null,
parentIssueId: params.target.parentIssueId ?? null,
assigneeAgentId: params.target.assigneeAgentId ?? null,
assigneeUserId: params.target.assigneeUserId ?? null,
},