mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Honor protected assignment policies
This commit is contained in:
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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user