Lock simple-mode assignment defaults

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-22 01:51:03 +00:00
1 parent 15cf25b794
commit 860a5d0e8b
6 files changed
+207 -49

No files matched your search

+1 -1
View File
@@ -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 },
});
+9
View File
@@ -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,
+78 -18
View File
@@ -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" &&
+3 -1
View File
@@ -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">