From a04a77c9d3c665c3653e232ef2a0b4ed7d3b1d91 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:51:48 -0500 Subject: [PATCH] feat(authz): govern agent inbox archive access (#9658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies and their work > - The inbox subsystem must let agents act for a responsible user without silently granting access to every company user's tasks > - Existing authorization had no inbox-specific action, target-user scope, or per-user agent policy > - Inbox archive data also needs company-safe ownership and replay-safe schema changes before API mutations can rely on it > - This pull request adds the database policy foundation and a fail-closed `inbox:manage` authorization decision > - The benefit is a least-privilege core for later inbox archive endpoints, including explicit cross-user grants and low-trust denial ## Linked Issues or Issue Description ### Subsystem affected Cross-cutting (`packages/db`, `packages/shared`, and `server`). ### Problem or motivation Agents need to manage inbox state for the user responsible for their run, but the control plane lacks an inbox-specific permission model and user-targeted grant scope. A generic mutation path would risk cross-user access or inconsistent policy enforcement. ### Proposed solution Add inbox archive ownership and per-user agent policies, introduce `inbox:manage`, and evaluate responsible-user defaults, disabled/allowlist policies, active membership, low-trust presets, and scoped cross-user grants in one authorization decision. ### Alternatives considered Reusing generic issue mutation permissions was rejected because it cannot express user-targeted inbox scope. Requiring grants for all self-user access was rejected because it would make the responsible-user path closed by default instead of using the requested per-user policy model. ### Roadmap alignment `ROADMAP.md` contains no overlapping inbox archive or inbox authorization item; this is incremental control-plane authorization work. ### Additional context This PR provides the authorization and schema foundation. Route and UI behavior can build on this decision without duplicating access-control rules. ## What Changed - Builds on the merged migration `0172_inbox_archive_agent_policies` (#9654) for company/user-scoped inbox archives and per-user agent policy rows. - Added replay-safe migration `0173_inbox_policy_agent_cleanup` with a GIN allowlist index and GIN-backed database cleanup that removes deleted agent IDs from policy allowlists. - Added Drizzle schema exports for inbox agent policies and responsible-user ownership on inbox archives. - Added the shared `inbox:manage` permission key and `scope.userIds` evaluation for user-targeted grants. - Added fail-closed inbox authorization for unresolved targets, inactive memberships, low-trust agents, disabled policies, allowlist misses, and ungranted cross-user access. - Added migration replay coverage and the full inbox authorization decision matrix. ## Verification - `pnpm exec vitest run packages/db/src/inbox-archive-agent-policies-migration.test.ts server/src/__tests__/authorization-service.test.ts` — 50 tests passed. - `pnpm --filter @paperclipai/db typecheck` - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/server typecheck` - `git diff --check origin/master...HEAD` ## Risks - The merged `0172` migration changed inbox archive uniqueness from agent-owned to responsible-user-owned rows; `0173` is additive (index + cleanup trigger) and idempotent, and replay coverage verifies both remain safe for databases that already applied an earlier form. - `scope.userIds` uses the existing JSON grant-scope parser, so malformed privileged grant payloads continue to fail through the shared parsing behavior rather than a dedicated schema. - Cross-user grants intentionally act as board-admin overrides; responsible-user default access remains bounded by disabled and allowlist policies. - The authorization action is not yet wired to public mutation routes, limiting immediate behavioral impact while establishing the contract those routes must use. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with `gpt-5.6-sol`, high reasoning effort, CLI tool use, code execution, GitHub CLI, and Paperclip control-plane integration. Context window size is not exposed by the configured adapter. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ...x-archive-agent-policies-migration.test.ts | 23 +- .../0173_inbox_policy_agent_cleanup.sql | 22 ++ packages/db/src/migrations/meta/_journal.json | 7 + .../src/schema/user_inbox_agent_policies.ts | 6 +- packages/shared/src/constants.ts | 1 + .../__tests__/authorization-service.test.ts | 267 ++++++++++++++++++ server/src/services/authorization.ts | 132 ++++++++- 7 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 packages/db/src/migrations/0173_inbox_policy_agent_cleanup.sql 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; }