diff --git a/packages/db/src/inbox-archive-agent-policies-migration.test.ts b/packages/db/src/inbox-archive-agent-policies-migration.test.ts index ab59a16358..dafec04ad3 100644 --- a/packages/db/src/inbox-archive-agent-policies-migration.test.ts +++ b/packages/db/src/inbox-archive-agent-policies-migration.test.ts @@ -49,6 +49,7 @@ describeEmbeddedPostgres("inbox archive agent policy migration", () => { const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); const companyId = randomUUID(); const agentId = randomUUID(); + const deletedAgentId = randomUUID(); const issueId = randomUUID(); const runId = randomUUID(); const legacyArchiveId = randomUUID(); @@ -73,6 +74,10 @@ describeEmbeddedPostgres("inbox archive agent policy migration", () => { INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config") VALUES (${agentId}, ${companyId}, 'Inbox agent', 'engineer', 'process', '{}'::jsonb) `; + await sql` + INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config") + VALUES (${deletedAgentId}, ${companyId}, 'Deleted inbox agent', 'engineer', 'process', '{}'::jsonb) + `; await sql` INSERT INTO "issues" ("id", "company_id", "title", "identifier") VALUES (${issueId}, ${companyId}, 'Legacy inbox issue', 'IAM-1') @@ -147,7 +152,7 @@ describeEmbeddedPostgres("inbox archive agent policy migration", () => { "mode", "allowed_agent_ids" ) - VALUES (${companyId}, 'agent-managed-user', 'allowlist', ${verifySql.json([agentId])}) + VALUES (${companyId}, 'agent-managed-user', 'allowlist', ${verifySql.json([agentId, deletedAgentId])}) `; const policies = await verifySql<{ mode: string; @@ -158,7 +163,21 @@ describeEmbeddedPostgres("inbox archive agent policy migration", () => { WHERE "company_id" = ${companyId} AND "user_id" = 'agent-managed-user' `; - expect(policies).toEqual([{ mode: "allowlist", allowed_agent_ids: [agentId] }]); + expect(policies).toEqual([{ + mode: "allowlist", + allowed_agent_ids: [agentId, deletedAgentId], + }]); + + await verifySql`DELETE FROM "agents" WHERE "id" = ${deletedAgentId}`; + const policiesAfterAgentDeletion = await verifySql<{ + allowed_agent_ids: string[]; + }[]>` + SELECT "allowed_agent_ids" + FROM "user_inbox_agent_policies" + WHERE "company_id" = ${companyId} + AND "user_id" = 'agent-managed-user' + `; + expect(policiesAfterAgentDeletion).toEqual([{ allowed_agent_ids: [agentId] }]); await expect(verifySql` INSERT INTO "user_inbox_agent_policies" ("company_id", "user_id", "mode") diff --git a/packages/db/src/migrations/0173_inbox_policy_agent_cleanup.sql b/packages/db/src/migrations/0173_inbox_policy_agent_cleanup.sql new file mode 100644 index 0000000000..9286a35635 --- /dev/null +++ b/packages/db/src/migrations/0173_inbox_policy_agent_cleanup.sql @@ -0,0 +1,22 @@ +CREATE INDEX IF NOT EXISTS "user_inbox_agent_policies_allowed_agent_ids_idx" ON "user_inbox_agent_policies" USING gin ("allowed_agent_ids"); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION "remove_deleted_agent_from_inbox_policy_allowlists"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE "user_inbox_agent_policies" + SET + "allowed_agent_ids" = "allowed_agent_ids" - OLD."id"::text, + "updated_at" = now() + WHERE "allowed_agent_ids" ? OLD."id"::text; + RETURN OLD; +END; +$$; +--> statement-breakpoint +DROP TRIGGER IF EXISTS "agents_cleanup_inbox_policy_allowlists" ON "agents"; +--> statement-breakpoint +CREATE TRIGGER "agents_cleanup_inbox_policy_allowlists" +AFTER DELETE ON "agents" +FOR EACH ROW +EXECUTE FUNCTION "remove_deleted_agent_from_inbox_policy_allowlists"(); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 5e4450b883..9f778c178b 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1198,6 +1198,13 @@ "when": 1784169959628, "tag": "0172_inbox_archive_agent_policies", "breakpoints": true + }, + { + "idx": 173, + "version": "7", + "when": 1784210753027, + "tag": "0173_inbox_policy_agent_cleanup", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/user_inbox_agent_policies.ts b/packages/db/src/schema/user_inbox_agent_policies.ts index 4709cee69c..bb3353fb73 100644 --- a/packages/db/src/schema/user_inbox_agent_policies.ts +++ b/packages/db/src/schema/user_inbox_agent_policies.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { check, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { check, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; export const userInboxAgentPolicies = pgTable( @@ -18,6 +18,10 @@ export const userInboxAgentPolicies = pgTable( table.companyId, table.userId, ), + allowedAgentIdsIdx: index("user_inbox_agent_policies_allowed_agent_ids_idx").using( + "gin", + table.allowedAgentIds, + ), modeCheck: check( "user_inbox_agent_policies_mode_check", sql`${table.mode} in ('open', 'allowlist', 'disabled')`, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 32ee7d609a..9a043b9060 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -920,6 +920,7 @@ export const PERMISSION_KEYS = [ "tools:view_audit", "tools:use", "tools:manage_runtime", + "inbox:manage", "users:invite", "users:manage_permissions", "tasks:assign", diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 62010ed16e..89e1af61a7 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -11,6 +11,7 @@ import { issues, principalPermissionGrants, projects, + userInboxAgentPolicies, } from "@paperclipai/db"; import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared"; import { @@ -172,6 +173,7 @@ describeEmbeddedPostgres("authorization service", () => { afterEach(async () => { await db.delete(issueComments); + await db.delete(userInboxAgentPolicies); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); await db.delete(instanceUserRoles); @@ -1880,4 +1882,269 @@ describeEmbeddedPostgres("authorization service", () => { reason: "deny_scope", }); }); + + it("allows responsible-user inbox management by default", async () => { + const company = await createCompany(db, "InboxDefaultOpen"); + const actorAgent = await createAgent(db, company.id); + const responsibleUserId = await createUser(db); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: responsibleUserId, + status: "active", + membershipRole: "operator", + }); + + await expect(authorizationService(db).decide({ + actor: { + type: "agent", + agentId: actorAgent.id, + companyId: company.id, + onBehalfOfUserId: responsibleUserId, + source: "agent_jwt", + }, + action: "inbox:manage", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_self" }); + }); + + it("denies responsible-user inbox management when disabled", async () => { + const company = await createCompany(db, "InboxDisabled"); + const actorAgent = await createAgent(db, company.id); + const responsibleUserId = await createUser(db); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: responsibleUserId, + status: "active", + membershipRole: "operator", + }); + await db.insert(userInboxAgentPolicies).values({ + companyId: company.id, + userId: responsibleUserId, + mode: "disabled", + }); + + await expect(authorizationService(db).decide({ + actor: { + type: "agent", + agentId: actorAgent.id, + companyId: company.id, + onBehalfOfUserId: responsibleUserId, + source: "agent_jwt", + }, + action: "inbox:manage", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ allowed: false, reason: "inbox_management_disabled" }); + }); + + it("enforces responsible-user inbox allowlists", async () => { + const company = await createCompany(db, "InboxAllowlist"); + const allowedAgent = await createAgent(db, company.id); + const deniedAgent = await createAgent(db, company.id); + const responsibleUserId = await createUser(db); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: responsibleUserId, + status: "active", + membershipRole: "operator", + }); + await db.insert(userInboxAgentPolicies).values({ + companyId: company.id, + userId: responsibleUserId, + mode: "allowlist", + allowedAgentIds: [allowedAgent.id], + }); + const decideFor = (agentId: string) => authorizationService(db).decide({ + actor: { + type: "agent" as const, + agentId, + companyId: company.id, + onBehalfOfUserId: responsibleUserId, + source: "agent_jwt" as const, + }, + action: "inbox:manage" as const, + resource: { type: "company" as const, companyId: company.id }, + }); + + await expect(decideFor(allowedAgent.id)).resolves.toMatchObject({ allowed: true, reason: "allow_self" }); + await expect(decideFor(deniedAgent.id)).resolves.toMatchObject({ + allowed: false, + reason: "inbox_agent_not_allowed", + }); + }); + + it("requires a grant for cross-user inbox management", async () => { + const company = await createCompany(db, "InboxCrossUserDenied"); + const actorAgent = await createAgent(db, company.id); + const responsibleUserId = await createUser(db); + const targetUserId = await createUser(db); + await db.insert(companyMemberships).values([ + { + companyId: company.id, + principalType: "user", + principalId: responsibleUserId, + status: "active", + membershipRole: "operator", + }, + { + companyId: company.id, + principalType: "user", + principalId: targetUserId, + status: "active", + membershipRole: "operator", + }, + ]); + + await expect(authorizationService(db).decide({ + actor: { + type: "agent", + agentId: actorAgent.id, + companyId: company.id, + onBehalfOfUserId: responsibleUserId, + source: "agent_jwt", + }, + action: "inbox:manage", + resource: { type: "company", companyId: company.id }, + scope: { userId: targetUserId }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_missing_grant" }); + }); + + it("allows cross-user inbox management with an unscoped grant", async () => { + const company = await createCompany(db, "InboxCrossUserGranted"); + const actorAgent = await createAgent(db, company.id); + const responsibleUserId = await createUser(db); + const targetUserId = await createUser(db); + await db.insert(companyMemberships).values([ + { + companyId: company.id, + principalType: "user", + principalId: responsibleUserId, + status: "active", + membershipRole: "operator", + }, + { + companyId: company.id, + principalType: "user", + principalId: targetUserId, + status: "active", + membershipRole: "operator", + }, + ]); + await grantAgentPermission(db, company.id, actorAgent.id, "inbox:manage"); + + await expect(authorizationService(db).decide({ + actor: { + type: "agent", + agentId: actorAgent.id, + companyId: company.id, + onBehalfOfUserId: responsibleUserId, + source: "agent_jwt", + }, + action: "inbox:manage", + resource: { type: "company", companyId: company.id }, + scope: { userId: targetUserId }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_explicit_grant" }); + }); + + it("enforces user-scoped cross-user inbox grants", async () => { + const company = await createCompany(db, "InboxCrossUserScoped"); + const actorAgent = await createAgent(db, company.id); + const responsibleUserId = await createUser(db); + const allowedTargetUserId = await createUser(db); + const deniedTargetUserId = await createUser(db); + await db.insert(companyMemberships).values([ + { + companyId: company.id, + principalType: "user", + principalId: responsibleUserId, + status: "active", + membershipRole: "operator", + }, + { + companyId: company.id, + principalType: "user", + principalId: allowedTargetUserId, + status: "active", + membershipRole: "operator", + }, + { + companyId: company.id, + principalType: "user", + principalId: deniedTargetUserId, + status: "active", + membershipRole: "operator", + }, + ]); + await grantAgentPermission(db, company.id, actorAgent.id, "inbox:manage", { + userIds: [allowedTargetUserId], + }); + const decideFor = (userId: string) => authorizationService(db).decide({ + actor: { + type: "agent" as const, + agentId: actorAgent.id, + companyId: company.id, + onBehalfOfUserId: responsibleUserId, + source: "agent_jwt" as const, + }, + action: "inbox:manage" as const, + resource: { type: "company" as const, companyId: company.id }, + scope: { userId }, + }); + + await expect(decideFor(allowedTargetUserId)).resolves.toMatchObject({ + allowed: true, + reason: "allow_explicit_grant", + }); + await expect(decideFor(deniedTargetUserId)).resolves.toMatchObject({ allowed: false, reason: "deny_scope" }); + }); + + it("denies inbox management when the target user cannot be resolved", async () => { + const company = await createCompany(db, "InboxUnresolved"); + const actorAgent = await createAgent(db, company.id); + + await expect(authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" }, + action: "inbox:manage", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ allowed: false, reason: "inbox_target_user_unresolved" }); + }); + + it("denies low-trust inbox management", async () => { + const company = await createCompany(db, "InboxLowTrust"); + const project = await createProject(db, company.id, "InboxLowTrust"); + const responsibleUserId = await createUser(db); + const actorAgent = await createAgent(db, company.id, { + permissions: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId: company.id, + projectIds: [project.id], + }, + }, + }, + }); + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: responsibleUserId, + status: "active", + membershipRole: "operator", + }); + + await expect(authorizationService(db).decide({ + actor: { + type: "agent", + agentId: actorAgent.id, + companyId: company.id, + onBehalfOfUserId: responsibleUserId, + source: "agent_jwt", + }, + action: "inbox:manage", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); + }); }); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index b57976cde8..bc42b96630 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -10,6 +10,7 @@ import { issues, principalPermissionGrants, projects, + userInboxAgentPolicies, } from "@paperclipai/db"; import type { AgentApiKeyScope, @@ -104,6 +105,9 @@ export type AuthorizationDecision = { | "allow_company_member" | "allow_simple_company_member" | "allow_manager_chain" + | "inbox_target_user_unresolved" + | "inbox_management_disabled" + | "inbox_agent_not_allowed" | "deny_unauthenticated" | "deny_company_boundary" | "deny_missing_membership" @@ -358,6 +362,7 @@ async function scopeAllows( ? requestedScope.targetAgentId : null; const requestedProjectId = typeof requestedScope.projectId === "string" ? requestedScope.projectId : null; + const requestedUserId = typeof requestedScope.userId === "string" ? requestedScope.userId : null; let constrained = false; const projectIds = [ @@ -386,6 +391,12 @@ async function scopeAllows( if (!scopeIncludesId(targetAgentIds, targetAssigneeAgentId)) return false; } + const targetUserIds = scopeValuesForKeys(grantScope, ["userId", "userIds"]); + if (targetUserIds.length > 0) { + constrained = true; + if (!scopeIncludesId(targetUserIds, requestedUserId)) return false; + } + const subtreeRootAgentIds = [ ...scopeValuesForKeys(grantScope, [ "managerAgentId", @@ -582,7 +593,9 @@ export function authorizationService(db: Db) { const requestMemo = actorWithMemo.__responsibleUserSnapshotMemo.get(key); if (requestMemo) return requestMemo; - const actorMembership = activeActorMembership(input.actor.onBehalfOfMemberships, input.companyId); + const actorMembership = input.actor.onBehalfOfUserId === input.userId + ? activeActorMembership(input.actor.onBehalfOfMemberships, input.companyId) + : null; if (actorMembership) { const promise = Promise.resolve({ userId: input.userId, @@ -913,6 +926,7 @@ export function authorizationService(db: Db) { input.action === "agent_config:read" || input.action === "agent_config:update" || input.action === "skill_config:update" || + input.action === "inbox:manage" || input.action === "runtime:manage" || input.action === "secrets:read" ) { @@ -1693,6 +1707,115 @@ export function authorizationService(db: Db) { } } + + if (input.action === "inbox:manage") { + if (!isSimpleAssignableAgentStatus(actorAgent.status)) { + return deny({ + action: input.action, + reason: "deny_missing_membership", + explanation: "Actor agent is not active in the target company.", + }); + } + const responsibleUserId = input.actor.onBehalfOfUserId?.trim() || null; + const explicitTargetUserId = typeof input.scope?.userId === "string" + ? input.scope.userId.trim() || null + : null; + const targetUserId = explicitTargetUserId ?? responsibleUserId; + if (!targetUserId) { + return deny({ + action: input.action, + reason: "inbox_target_user_unresolved", + explanation: "Inbox target user could not be resolved from the request or responsible-user context.", + }); + } + + const targetSnapshot = await getResponsibleUserSnapshot({ + actor: input.actor, + companyId, + userId: targetUserId, + }); + if (!targetSnapshot.userExists || !targetSnapshot.activeMembership) { + return deny({ + action: input.action, + reason: "deny_missing_membership", + explanation: `Inbox target user ${targetUserId} is not an active member of company ${companyId}.`, + }); + } + + if (targetUserId !== responsibleUserId) { + // Cross-user grants are board-admin overrides; user policies only govern responsible-user default access. + const grant = await findGrant(companyId, "agent", actorAgentId, "inbox:manage"); + if (!grant) { + return deny({ + action: input.action, + reason: "deny_missing_grant", + explanation: "Missing permission: inbox:manage.", + }); + } + if (!(await scopeAllows(db, companyId, grant.scope, { userId: targetUserId }))) { + return deny({ + action: input.action, + reason: "deny_scope", + explanation: "Permission inbox:manage does not cover the requested user.", + grant: { + principalType: "agent", + principalId: actorAgentId, + permissionKey: "inbox:manage", + scope: grant.scope ?? null, + }, + }); + } + return allow({ + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by explicit grant inbox:manage.", + grant: { + principalType: "agent", + principalId: actorAgentId, + permissionKey: "inbox:manage", + scope: grant.scope ?? null, + }, + }); + } + + const policy = await db + .select({ + mode: userInboxAgentPolicies.mode, + allowedAgentIds: userInboxAgentPolicies.allowedAgentIds, + }) + .from(userInboxAgentPolicies) + .where( + and( + eq(userInboxAgentPolicies.companyId, companyId), + eq(userInboxAgentPolicies.userId, targetUserId), + ), + ) + .then((rows) => rows[0] ?? null); + + if (policy?.mode === "disabled") { + return deny({ + action: input.action, + reason: "inbox_management_disabled", + explanation: `Inbox management is disabled for user ${targetUserId}.`, + }); + } + if (policy?.mode === "allowlist" && !policy.allowedAgentIds.includes(actorAgentId)) { + return deny({ + action: input.action, + reason: "inbox_agent_not_allowed", + explanation: `Agent ${actorAgentId} is not allowed to manage user ${targetUserId}'s inbox.`, + }); + } + + return allow({ + action: input.action, + reason: "allow_self", + explanation: policy?.mode === "allowlist" + ? "Allowed by the responsible user's inbox agent allowlist." + : "Allowed by the responsible user's default-open inbox policy.", + }); + } + if ( input.action === "agent:read" || input.action === "company_scope:read" || @@ -1870,7 +1993,12 @@ export function authorizationService(db: Db) { agentDecision: AuthorizationDecision, ): Promise { const responsibleUserId = input.actor.onBehalfOfUserId?.trim(); - if (input.actor.type !== "agent" || !responsibleUserId || !agentDecision.allowed) { + if ( + input.actor.type !== "agent" || + input.action === "inbox:manage" || + !responsibleUserId || + !agentDecision.allowed + ) { return agentDecision; }