mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Lock simple-mode assignment defaults
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
15cf25b794
commit
860a5d0e8b
6 files changed
+207
-49
No files matched your search
@@ -58,7 +58,7 @@ export interface AgentInstructionsBundle {
|
||||
|
||||
export interface AgentAccessState {
|
||||
canAssignTasks: boolean;
|
||||
taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none";
|
||||
taskAssignSource: "simple_default" | "explicit_grant" | "agent_creator" | "ceo_role" | "none";
|
||||
membership: CompanyMembership | null;
|
||||
grants: PrincipalPermissionGrant[];
|
||||
}
|
||||
|
||||
@@ -1342,6 +1342,24 @@ describe.sequential("agent permission routes", () => {
|
||||
expect(res.body.access.taskAssignSource).toBe("explicit_grant");
|
||||
}, 15_000);
|
||||
|
||||
it("reports simple-mode task assignment as enabled for active company agent members", async () => {
|
||||
mockAccessService.listPrincipalGrants.mockResolvedValue([]);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.access.canAssignTasks).toBe(true);
|
||||
expect(res.body.access.taskAssignSource).toBe("simple_default");
|
||||
}, 15_000);
|
||||
|
||||
it("keeps task assignment enabled when agent creation privilege is enabled", async () => {
|
||||
mockAgentService.updatePermissions.mockResolvedValue({
|
||||
...baseAgent,
|
||||
|
||||
@@ -125,10 +125,12 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
grantedByUserId: "owner",
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "board", userId, source: "session" },
|
||||
const decision = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
action: "tasks:assign",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
permissionKey: "tasks:assign",
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
@@ -190,9 +192,10 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
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");
|
||||
it("allows simple-mode task assignment between same-company agents without explicit grants", async () => {
|
||||
const company = await createCompany(db, "AssignmentDefault");
|
||||
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
|
||||
const targetAgent = await createAgent(db, company.id, { role: "engineer" });
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
@@ -204,14 +207,66 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
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 },
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_simple_company_member",
|
||||
});
|
||||
expect(decision.explanation).toContain("simple mode");
|
||||
});
|
||||
|
||||
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()}`;
|
||||
const targetAgent = await createAgent(db, company.id, { role: "engineer" });
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "board", userId, source: "session" },
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_simple_company_member",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies simple-mode assignment to a target agent from another company", async () => {
|
||||
const sourceCompany = await createCompany(db, "AssignmentSource");
|
||||
const targetCompany = await createCompany(db, "AssignmentTarget");
|
||||
const actorAgent = await createAgent(db, sourceCompany.id, { role: "engineer" });
|
||||
const targetAgent = await createAgent(db, targetCompany.id, { role: "engineer" });
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: sourceCompany.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: sourceCompany.id, source: "agent_key" },
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: sourceCompany.id, assigneeAgentId: targetAgent.id },
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_missing_grant",
|
||||
reason: "deny_company_boundary",
|
||||
});
|
||||
expect(decision.explanation).toContain("Missing permission: tasks:assign");
|
||||
});
|
||||
|
||||
it("preserves legacy CEO agent creator authority", async () => {
|
||||
@@ -240,16 +295,20 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
projectIds: [project.id],
|
||||
});
|
||||
|
||||
const allowed = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
const allowed = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, projectId: project.id, assigneeAgentId: targetAgent.id },
|
||||
permissionKey: "tasks:assign_scope",
|
||||
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" },
|
||||
const denied = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, projectId: otherProject.id, assigneeAgentId: targetAgent.id },
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { projectId: otherProject.id, assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
@@ -275,16 +334,20 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
managedSubtreeAgentIds: [managerAgent.id],
|
||||
});
|
||||
|
||||
const allowed = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
const allowed = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: grandchildAgent.id },
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { assigneeAgentId: grandchildAgent.id },
|
||||
});
|
||||
const denied = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
const denied = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: outsideAgent.id },
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { assigneeAgentId: outsideAgent.id },
|
||||
});
|
||||
|
||||
@@ -305,16 +368,20 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
assigneeAgentIds: [allowedTarget.id],
|
||||
});
|
||||
|
||||
const allowed = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
const allowed = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: allowedTarget.id },
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { assigneeAgentId: allowedTarget.id },
|
||||
});
|
||||
const denied = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
const denied = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: deniedTarget.id },
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: { assigneeAgentId: deniedTarget.id },
|
||||
});
|
||||
|
||||
@@ -328,10 +395,12 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
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" },
|
||||
const decision = await authorizationService(db).decidePrincipalGrant({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: company.id, assigneeAgentId: targetAgent.id },
|
||||
permissionKey: "tasks:assign",
|
||||
scope: { assigneeAgentId: targetAgent.id },
|
||||
});
|
||||
|
||||
|
||||
@@ -501,6 +501,15 @@ export function agentRoutes(
|
||||
};
|
||||
}
|
||||
|
||||
if (membership?.status === "active") {
|
||||
return {
|
||||
canAssignTasks: true,
|
||||
taskAssignSource: "simple_default" as const,
|
||||
membership,
|
||||
grants,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canAssignTasks: false,
|
||||
taskAssignSource: "none" as const,
|
||||
|
||||
@@ -58,6 +58,7 @@ export type AuthorizationDecision = {
|
||||
| "allow_legacy_agent_creator"
|
||||
| "allow_self"
|
||||
| "allow_company_agent"
|
||||
| "allow_simple_company_member"
|
||||
| "allow_manager_chain"
|
||||
| "deny_unauthenticated"
|
||||
| "deny_company_boundary"
|
||||
@@ -116,6 +117,10 @@ function scopeIncludesId(ids: string[], id: string | null | undefined) {
|
||||
return Boolean(id && ids.includes(id));
|
||||
}
|
||||
|
||||
function isSimpleAssignableAgentStatus(status: string | null | undefined) {
|
||||
return status !== "pending_approval" && status !== "terminated";
|
||||
}
|
||||
|
||||
async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) {
|
||||
if (rootAgentId === targetAgentId) return true;
|
||||
|
||||
@@ -334,6 +339,7 @@ export function authorizationService(db: Db) {
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
role: agents.role,
|
||||
status: agents.status,
|
||||
reportsTo: agents.reportsTo,
|
||||
permissions: agents.permissions,
|
||||
})
|
||||
@@ -342,6 +348,22 @@ export function authorizationService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function assignmentTargetIsInCompany(resource: AuthorizationResource) {
|
||||
if (resource.type !== "issue") return true;
|
||||
if (resource.assigneeAgentId) {
|
||||
const target = await loadAgent(resource.assigneeAgentId);
|
||||
return Boolean(
|
||||
target &&
|
||||
target.companyId === resource.companyId &&
|
||||
isSimpleAssignableAgentStatus(target.status),
|
||||
);
|
||||
}
|
||||
if (resource.assigneeUserId) {
|
||||
return Boolean(await getActiveMembership(resource.companyId, "user", resource.assigneeUserId));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function isManagerOf(companyId: string, managerAgentId: string, assigneeAgentId: string) {
|
||||
const rows = await db
|
||||
.select({ id: agents.id, reportsTo: agents.reportsTo })
|
||||
@@ -423,6 +445,30 @@ export function authorizationService(db: Db) {
|
||||
explanation: "Board user id is required.",
|
||||
});
|
||||
}
|
||||
if (input.action === "tasks:assign") {
|
||||
if (!(await assignmentTargetIsInCompany(input.resource))) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_company_boundary",
|
||||
explanation: "Task assignment target agent is not active in the target company.",
|
||||
});
|
||||
}
|
||||
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
|
||||
if (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)) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_simple_company_member",
|
||||
explanation: "Allowed by legacy company membership context.",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!permissionKey) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
@@ -459,6 +505,37 @@ export function authorizationService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
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 === "tasks:assign") {
|
||||
if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_missing_membership",
|
||||
explanation: "Actor agent is not active for simple mode task assignment.",
|
||||
});
|
||||
}
|
||||
if (!(await assignmentTargetIsInCompany(input.resource))) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_company_boundary",
|
||||
explanation: "Task assignment target agent is not active in the target company.",
|
||||
});
|
||||
}
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_simple_company_member",
|
||||
explanation: "Allowed by simple mode company-wide task assignment default.",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === "issue:mutate") {
|
||||
const resource = input.resource.type === "issue" ? input.resource : null;
|
||||
if (resource?.assigneeAgentId === actorAgentId) {
|
||||
@@ -488,12 +565,7 @@ export function authorizationService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
if (permissionKey) {
|
||||
const grantDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType: "agent",
|
||||
@@ -505,20 +577,10 @@ export function authorizationService(db: Db) {
|
||||
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)
|
||||
) {
|
||||
@@ -529,8 +591,6 @@ export function authorizationService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
if (deniedGrantDecision) return deniedGrantDecision;
|
||||
|
||||
if (
|
||||
input.action === "tasks:manage_active_checkouts" &&
|
||||
input.resource.type === "issue" &&
|
||||
|
||||
@@ -1713,7 +1713,9 @@ function ConfigurationTab({
|
||||
? "Enabled automatically while this agent can create new agents."
|
||||
: taskAssignSource === "explicit_grant"
|
||||
? "Enabled via explicit company permission grant."
|
||||
: "Disabled unless explicitly granted.";
|
||||
: taskAssignSource === "simple_default"
|
||||
? "Enabled by simple company-wide task assignment defaults."
|
||||
: "Disabled unless explicitly granted.";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
Reference in new issue
Block a user