feat(authz): govern agent inbox archive access (#9658)
## 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 <noreply@paperclip.ing>
This commit is contained in:
parent
c65ab09d9f
commit
a04a77c9d3
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"();
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<AuthorizationDecision> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue