mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Cloud-managed instances authenticate tenant users through a trusted-header path (`resolveCloudTenantActor`) that deliberately never grants `instance_admin`, so every tenant user is company-scoped > - On a dedicated (single-owner) managed instance that leaves the paying owner unable to administer their own instance: instance settings, the environments admin surface, and the custom sandbox image flow are all instance-admin gated (the environments UI can't even show the provider/image of the platform sandbox because the restricted read view blanks `config` entirely) > - Re-granting the old blanket `instance_user_roles` row would repeat the mistake the shared-pool hardening fixed: DB rows go stale, resurrect via restores, and elevate through every auth path > - This pull request elevates only the stack `owner`, computed per request at the trusted-header boundary behind a new managed-tier feature flag, and ships that elevation together with code floors on the platform-owned surfaces an instance admin must not control on a managed instance > - The benefit is that dedicated-stack owners can administer their own instance while platform credentials, execution policy, backups, and runtime code-install stay platform-owned, and self-hosted behavior is unchanged ## Linked Issues or Issue Description No public GitHub issue exists for this change; the underlying issue is described here following the feature-request template. ### Problem or motivation - On cloud-managed instances, tenant users resolved from trusted headers are always company-scoped. For dedicated instances with a single paying owner, the owner cannot reach any instance-admin surface of their own instance (instance settings, environments administration, custom image setup), and the restricted environment read view hides even structural fields like the sandbox provider and image. - The previous hardening intentionally removed blanket elevation (and purges stale `instance_user_roles` rows on every trusted-header authentication). That protection must not regress for shared multi-tenant pools. ### Proposed solution - Owner-only, computed, flag-gated elevation plus code floors on platform-owned surfaces, in one PR so the elevation can never ship without the floors. ### Alternatives considered - Re-inserting an `instance_user_roles` row for owners (the pre-hardening model): rejected — DB rows go stale, survive restores, and elevate through every auth path; #7525 removed exactly this. - Widening only the environments read view without any elevation: rejected — it fixes one screen but still leaves a dedicated-instance owner unable to administer instance settings or custom images. - Elevating additional stack roles (`member`/`admin`/`support`): rejected — only the owner has an ownership claim over the whole instance; other roles stay company-scoped. ### Roadmap alignment - Extends the shipped "Cloud deployments" roadmap work (multi-tenant isolation, company-scoped cloud tenants, managed-instance bootstrap) without overlapping planned core items, and leaves self-hosted behavior unchanged. ## What Changed - **New feature key** `enableOwnerInstanceAdmin` (`packages/shared`): boolean flag in `instanceExperimentalSettingsSchema`, catalog tier `managed`, `cloudDefault: true`, `selfHostedDefault: false`. Inert on self-hosted instances — the elevation path only exists behind the cloud tenant trust token. - **Computed elevation** (`server/src/middleware/auth.ts`): `resolveCloudTenantActor` now returns `isInstanceAdmin: true` only when the trusted-header stack role is `owner` **and** the flag is enabled. The flag is resolved through the instance-settings service so the managed-config overlay applies (the control plane can disable elevation fleet-wide without touching tenant databases; a DB row edit or restore cannot resurrect it). Resolution fails closed on settings read errors. The `instance_user_roles` never-insert and the per-request stale-row purge are byte-identical. `member`/`admin`/`support` stack roles stay company-scoped. - **Authorization guard split** (`server/src/services/authorization.ts`): the blanket-allow now trusts the actor's *computed* `isInstanceAdmin` flag (only the attested resolver can set it for `cloud_tenant` actors) while keeping the `instance_user_roles` DB lookup excluded for `cloud_tenant` — a stale or hand-inserted role row still elevates nothing. - **Floor F1 — platform environment credentials** (`server/src/routes/environments.ts`): on cloud-managed instances, platform-provisioned environment rows (`managedByPaperclip` marker, plus the legacy managed-Kubernetes marker) use a single floored view for every reader on all environment routes (list, get, create, update, delete responses): `envVars` are never echoed and credential-shaped `config` keys (reusing the managed-config `SECRET_LIKE_CONFIG_KEY_PATTERN`) are dropped — for **all** actors including instance admins — while structural config (provider, image, template, region, …) and the managed markers stay visible. This also fixes the environments UI for managed sandboxes, which previously lost the provider/image entirely in the restricted view. The floor also covers writes: `PATCH /environments/:id` and `DELETE /environments/:id` on a platform-provisioned row are rejected (403, `environment_platform_managed`) for every actor including instance admins, and the guard binds to the persisted row's markers so a patch cannot strip the managed marker to lift the floor. The one recovery path is a metadata-only PATCH that solely clears the marker keys (null/false), for rows stamped through the old unrestricted API before the markers became reserved — and it never applies to a row whose slot markers are live platform state: the single local row (`environments_local_driver_idx`), which `ensureLocalEnvironment` adopts and stamps on cloud-managed instances from every caller (company creation, the heartbeat, run orchestration), and the single marked sandbox row (`environments_managed_sandbox_idx`) while a managed-sandbox bootstrap path is configured (managed-config `environments` section or `PAPERCLIP_EXECUTION_MODE=kubernetes`) and the provisioner therefore adopts and refreshes it on every boot. Clearing a live slot row's markers would let the next write reclassify it as tenant-managed and bypass the floor; conversely, when no sandbox provisioning path is configured the platform holds no claim on any sandbox row, so a platform marker there is stale by definition and the recovery patch applies. Every marker outside a live slot is clearable, so no legacy row is ever locked permanently. Custom-image setup and probes on the platform sandbox stay available to instance admins — those are the owner-facing flows this elevation exists for. The marker keys themselves are reserved: client create/update payloads that set `managedByPaperclip` or `managedKubernetesSandbox` are rejected (422, `environment_platform_marker_reserved`) on cloud-managed instances, so a tenant row can never be stamped platform-provisioned through the API and self-locked behind the write floor (the provisioner writes markers at the service layer, not through these routes). Tenant-created environments are otherwise unaffected. - **Floor F2 — executionMode** (`server/src/routes/instance-settings.ts`): on cloud-managed instances, `PATCH /instance/settings/general` rejects writes that would change `executionMode` (403, `execution_mode_platform_managed`). Same-value echoes pass so settings forms that submit the full general-settings object keep working. The boot-time execution-policy bootstrap path is untouched (it calls the service directly). - **Floor F3 — manual database backups** (`server/src/routes/instance-database-backups.ts`): floored off on cloud-managed instances (403, `database_backups_platform_managed`); backups are platform-owned there, and the result would also echo a server-side filesystem path. - **Floor F4 — adapter code install** (`server/src/routes/adapters.ts`): `POST /adapters/install` and `POST /adapters/:type/reinstall` are floored off on cloud-managed instances (403, `adapter_install_platform_managed`). Adapter packages execute in the server process, so a runtime install would let an instance admin read the platform trust anchors out of the process environment. This mirrors the existing bundled-only plugin install floor; adapter code on managed instances comes bundled with the platform image. ## Instance-admin surface audit Before widening who can hold `isInstanceAdmin`, every instance-admin-gated surface in `server/src` was enumerated and reviewed for whether its response or side effects could echo process environment values or platform credentials (tenant trust token, JWT signing keys, database connection strings, provider API keys): 29 distinct gate definitions covering ~90+ call sites, in four groups — sole instance-admin gates (12), instance-admin-or-company-permission gates (10), response-shaping/scope-widening sites (6), and the central `allow_instance_admin` short-circuit in the authorization service (58 `decide()` call sites). Findings and dispositions: - **Environment read/write responses** exposed platform sandbox `envVars`/credential-shaped config to instance admins → closed by floor F1. - **Manual backup trigger** echoed a server filesystem path and triggers a platform-owned operation → closed by floor F3. - **Adapter install/reinstall** loads externally fetched code into the server process (indirect, complete env exposure) → closed by floor F4. The sibling plugin-install path already had a bundled-only floor on managed instances and needed no change. - **Token-minting surfaces** (gateway tokens, custom-image terminal/connection tokens) mint credentials scoped to the instance's own resources, not platform trust anchors → acceptable for an owner-admin of a dedicated instance; unchanged. - All remaining gated surfaces return ordinary instance-scoped business data; none echo `process.env` or platform secrets directly. OAuth client secrets are referenced by env-var *name* only; SSH private keys are stored as secret refs before persistence and are not echoed. Operational note for managed platforms: this model assumes the process environment of a managed instance holds only that instance's own credentials. Platform operators should keep provider credentials per-instance (never fleet-shared) since an instance admin ultimately controls in-process code on their own instance. ## Verification - `pnpm vitest run server/src/middleware/cloud-tenant-actor.test.ts` — resolver matrix: owner × flag on/off, flag via managed overlay (on-over-DB-off and off-over-DB-on), member/admin/support × flag on, no-token self-hosted, fail-closed settings read, purge still runs and no role row is ever inserted (14 tests). - `pnpm vitest run server/src/__tests__/authorization-service.test.ts` — computed flag elevates a `cloud_tenant` actor; a stale `instance_user_roles` row still never does; `session` actors unchanged (full suite, embedded Postgres). - `pnpm vitest run server/src/__tests__/environment-routes.test.ts` — F1: no secret echo to admins on get/list, structural config visible to restricted readers, platform-row PATCH/DELETE rejected for admins (including a marker-stripping patch), marker-clear recovery allowed for stale legacy rows and for a marked sandbox row when no provisioning path is configured, but refused on the managed local row and on the sandbox slot row under a managed-config `environments` entry or the forced kubernetes execution mode, client marker-stamping creates/patches rejected, tenant rows still readable and writable, self-hosted read+write regression (60 tests). - `pnpm vitest run server/src/__tests__/environment-service.test.ts` — `ensureLocalEnvironment` adopts a pre-existing local row on cloud-managed instances (marker stamped, other metadata preserved, idempotent — no rewrite on re-ensure) and leaves self-hosted rows untouched (22 tests, embedded Postgres). - `pnpm vitest run server/src/__tests__/instance-settings-routes.test.ts server/src/__tests__/instance-database-backups-routes.test.ts` — F2 change-vs-echo matrix incl. self-hosted regression; F3 floor for both admin shapes (32 tests). - `pnpm vitest run server/src/__tests__/adapter-routes-authz.test.ts` — F4 floor; self-hosted install/reinstall behavior unchanged (existing cases). - `pnpm vitest run server/src/__tests__/first-admin-claim.test.ts server/src/__tests__/bootstrap-claim-routes.test.ts server/src/__tests__/managed-config.test.ts server/src/__tests__/health.test.ts server/src/__tests__/instance-settings-managed-overlay.test.ts server/src/services/managed-environments.test.ts server/src/services/execution-policy-bootstrap.test.ts` — first-admin bootstrap gate and managed-config behavior unchanged (91 tests). - `pnpm vitest run packages/shared/src/feature-catalog.test.ts` — catalog/schema sync tests cover the new key (selfHostedDefault must equal the schema default). - `pnpm run typecheck` — all 31 workspace projects clean. ## Risks - Self-hosted behavior is unchanged: every floor binds to `isCloudManagedInstance()` (tenant trust token present), the new flag defaults off with no elevation path, and regression tests pin the self-hosted branches. - The elevation is fail-closed and stateless: turning the flag off (managed overlay or DB) de-elevates on the next request; there is no role row to clean up and restores cannot resurrect elevation. - On a cloud-managed instance a pre-existing unmarked local row is adopted (stamped `managedByPaperclip`) by the next ensure and becomes platform-owned — the intended managed-product semantic: the platform owns the single local slot. Self-hosted instances are untouched. - F1 widens restricted readers' view of platform-provisioned rows from fully blanked `config`/`metadata` to structural-only `config` plus markers. Platform-delivered config is guaranteed secret-free by the managed-config contract (secret-shaped keys fail startup), and the floor re-drops secret-shaped keys defensively. - One extra instance-settings read per trusted-header request for owner-role actors (the resolver already performs several queries per request). ## Model Used Claude Fable 5 (Anthropic) — model id `claude-fable-5`, extended thinking enabled, agentic tool use via Claude Code; read-only explore subagents on the same model were used for the surface audit sweep. ## 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 - [ ] 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>
2188 lines
74 KiB
TypeScript
2188 lines
74 KiB
TypeScript
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
authUsers,
|
|
companyMemberships,
|
|
heartbeatRuns,
|
|
instanceUserRoles,
|
|
issueComments,
|
|
issues,
|
|
principalPermissionGrants,
|
|
projects,
|
|
userInboxAgentPolicies,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
AgentApiKeyScope,
|
|
InboxAgentPolicyMode,
|
|
PermissionKey,
|
|
PrincipalType,
|
|
SkillTestAgentKeyScope,
|
|
TaskBridgeAgentKeyScope,
|
|
} from "@paperclipai/shared";
|
|
import { LOW_TRUST_REVIEW_PRESET, extractAgentMentionIds, type LowTrustBoundary } from "@paperclipai/shared";
|
|
import {
|
|
LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH,
|
|
isIssueWithinLowTrustBoundary,
|
|
resolveCoreTrustPreset,
|
|
type TrustPresetResolution,
|
|
} from "./trust-preset-resolver.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
|
|
export type AuthorizationActor =
|
|
{
|
|
type: "board" | "agent" | "none";
|
|
userId?: string | null;
|
|
sessionId?: string | null;
|
|
companyIds?: string[];
|
|
memberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>;
|
|
onBehalfOfMemberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>;
|
|
isInstanceAdmin?: boolean;
|
|
ignoreInstanceAdmin?: boolean;
|
|
agentId?: string | null;
|
|
companyId?: string | null;
|
|
keyId?: string | null;
|
|
keyScope?: AgentApiKeyScope | null;
|
|
runId?: string | null;
|
|
onBehalfOfUserId?: string | null;
|
|
source?:
|
|
| "local_implicit"
|
|
| "session"
|
|
| "board_key"
|
|
| "agent_key"
|
|
| "agent_jwt"
|
|
| "cloud_tenant"
|
|
| "none";
|
|
};
|
|
|
|
export type AuthorizationAction =
|
|
| PermissionKey
|
|
| "agent_config:read"
|
|
| "agent_config:update"
|
|
| "skill_config:update"
|
|
| "agent:read"
|
|
| "agent:wake"
|
|
| "company_scope:read"
|
|
| "issue:comment"
|
|
| "issue:mutate"
|
|
| "issue:read"
|
|
| "project:read"
|
|
| "runtime:manage"
|
|
| "secrets:read";
|
|
|
|
export type AuthorizationResource =
|
|
| { type: "company"; companyId: string }
|
|
| { type: "agent"; companyId: string; agentId?: string | null }
|
|
| { type: "project"; companyId: string; projectId?: string | null }
|
|
| {
|
|
type: "issue";
|
|
companyId: string;
|
|
issueId?: string | null;
|
|
projectId?: string | null;
|
|
parentIssueId?: string | null;
|
|
assigneeAgentId?: string | null;
|
|
assigneeUserId?: string | null;
|
|
originKind?: string | null;
|
|
originId?: string | null;
|
|
status?: string | null;
|
|
};
|
|
|
|
export type AuthorizationDecision = {
|
|
allowed: boolean;
|
|
action: AuthorizationAction;
|
|
explanation: string;
|
|
inboxPolicyMode?: InboxAgentPolicyMode | "grant_override";
|
|
code?: "RESPONSIBLE_USER_UNAUTHORIZED" | "RESPONSIBLE_USER_UNAVAILABLE";
|
|
reason:
|
|
| "allow_low_trust_boundary"
|
|
| "allow_local_board"
|
|
| "allow_instance_admin"
|
|
| "allow_explicit_grant"
|
|
| "allow_direct_change"
|
|
| "allow_consented_change"
|
|
| "allow_legacy_agent_creator"
|
|
| "allow_issue_mention_grant"
|
|
| "allow_direct_parent_report"
|
|
| "allow_self"
|
|
| "allow_company_agent"
|
|
| "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"
|
|
| "deny_missing_grant"
|
|
| "deny_missing_consent"
|
|
| "deny_no_grant"
|
|
| "deny_policy_restricted"
|
|
| "deny_low_trust_boundary"
|
|
| "deny_scope"
|
|
| "deny_unsupported_action";
|
|
grant?: {
|
|
principalType: PrincipalType;
|
|
principalId: string;
|
|
permissionKey: PermissionKey;
|
|
scope: Record<string, unknown> | null;
|
|
};
|
|
};
|
|
|
|
type PrincipalGrantDecision = AuthorizationDecision & {
|
|
grant?: NonNullable<AuthorizationDecision["grant"]>;
|
|
};
|
|
|
|
function companyIdForResource(resource: AuthorizationResource) {
|
|
return resource.companyId;
|
|
}
|
|
|
|
function permissionForAction(action: AuthorizationAction): PermissionKey | null {
|
|
if (action === "agent_config:read" || action === "agent_config:update" || action === "skill_config:update") {
|
|
return null;
|
|
}
|
|
if (
|
|
action === "agent:read" ||
|
|
action === "agent:wake" ||
|
|
action === "company_scope:read" ||
|
|
action === "issue:read" ||
|
|
action === "project:read" ||
|
|
action === "runtime:manage" ||
|
|
action === "secrets:read"
|
|
) {
|
|
return null;
|
|
}
|
|
if (action === "issue:comment" || action === "issue:mutate") return null;
|
|
return action;
|
|
}
|
|
|
|
function canCreateAgentsLegacy(agent: { role: string; permissions: unknown }) {
|
|
if (agent.role === "ceo") return true;
|
|
if (!agent.permissions || typeof agent.permissions !== "object") return false;
|
|
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
|
}
|
|
|
|
function scopeValueList(value: unknown): string[] {
|
|
if (typeof value === "string" && value.trim()) return [value.trim()];
|
|
if (!Array.isArray(value)) return [];
|
|
return value
|
|
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
.map((entry) => entry.trim());
|
|
}
|
|
|
|
function prefixedScopeValues(grantScope: Record<string, unknown>, prefix: string) {
|
|
return scopeValueList(grantScope.allow)
|
|
.filter((rule) => rule.startsWith(prefix))
|
|
.map((rule) => rule.slice(prefix.length))
|
|
.filter((value) => value.length > 0);
|
|
}
|
|
|
|
function scopeValuesForKeys(grantScope: Record<string, unknown>, keys: string[]) {
|
|
return keys.flatMap((key) => scopeValueList(grantScope[key]));
|
|
}
|
|
|
|
function scopeIncludesId(ids: string[], id: string | null | undefined) {
|
|
return Boolean(id && ids.includes(id));
|
|
}
|
|
|
|
function isSimpleAssignableAgentStatus(status: string | null | undefined) {
|
|
return status !== "pending_approval" && status !== "terminated";
|
|
}
|
|
|
|
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 };
|
|
|
|
type AgentHierarchyRow = { id: string; reportsTo: string | null };
|
|
type LowTrustBoundaryWithCompany = LowTrustBoundary & { companyId: string };
|
|
type AgentAuthorizationRow = {
|
|
id: string;
|
|
companyId: string;
|
|
role: string;
|
|
status: string;
|
|
reportsTo: string | null;
|
|
permissions: Record<string, unknown> | null | undefined;
|
|
};
|
|
type ProjectAuthorizationRow = {
|
|
id: string;
|
|
companyId: string;
|
|
executionWorkspacePolicy: unknown;
|
|
};
|
|
type IssueAuthorizationRow = {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
parentId: string | null;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
checkoutRunId: string | null;
|
|
status: string;
|
|
executionPolicy: unknown;
|
|
originKind: string | null;
|
|
originId: string | null;
|
|
};
|
|
|
|
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" };
|
|
}
|
|
|
|
function agentIsInSubtree(
|
|
agentsById: Map<string, AgentHierarchyRow>,
|
|
rootAgentId: string,
|
|
targetAgentId: string,
|
|
) {
|
|
if (rootAgentId === targetAgentId) return true;
|
|
|
|
let cursor: string | null = targetAgentId;
|
|
for (let depth = 0; cursor && depth < 50; depth += 1) {
|
|
const current = agentsById.get(cursor);
|
|
if (!current) return false;
|
|
if (current.reportsTo === rootAgentId) return true;
|
|
cursor = current.reportsTo;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function loadCompanyAgentHierarchy(db: Db, companyId: string) {
|
|
const rows = await db
|
|
.select({ id: agents.id, reportsTo: agents.reportsTo })
|
|
.from(agents)
|
|
.where(eq(agents.companyId, companyId));
|
|
return new Map(rows.map((agent) => [agent.id, agent]));
|
|
}
|
|
|
|
async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) {
|
|
return agentIsInSubtree(
|
|
await loadCompanyAgentHierarchy(db, companyId),
|
|
rootAgentId,
|
|
targetAgentId,
|
|
);
|
|
}
|
|
|
|
async function scopeAllows(
|
|
db: Db,
|
|
companyId: string,
|
|
grantScope: Record<string, unknown> | null,
|
|
requestedScope: Record<string, unknown> | null | undefined,
|
|
options: { requireStructuredScope?: boolean } = {},
|
|
) {
|
|
if (!grantScope || Object.keys(grantScope).length === 0) return !options.requireStructuredScope;
|
|
if (!requestedScope) return false;
|
|
|
|
const targetAssigneeAgentId =
|
|
typeof requestedScope.assigneeAgentId === "string"
|
|
? requestedScope.assigneeAgentId
|
|
: typeof requestedScope.targetAgentId === "string"
|
|
? 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 = [
|
|
...scopeValueList(grantScope.projectId),
|
|
...scopeValueList(grantScope.projectIds),
|
|
...prefixedScopeValues(grantScope, "project:"),
|
|
];
|
|
if (projectIds.length > 0) {
|
|
constrained = true;
|
|
if (!scopeIncludesId(projectIds, requestedProjectId)) return false;
|
|
}
|
|
|
|
const targetAgentIds = [
|
|
...scopeValuesForKeys(grantScope, [
|
|
"agentId",
|
|
"agentIds",
|
|
"assigneeAgentId",
|
|
"assigneeAgentIds",
|
|
"targetAgentId",
|
|
"targetAgentIds",
|
|
]),
|
|
...prefixedScopeValues(grantScope, "agent:"),
|
|
];
|
|
if (targetAgentIds.length > 0) {
|
|
constrained = true;
|
|
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",
|
|
"managerAgentIds",
|
|
"managedSubtreeAgentId",
|
|
"managedSubtreeAgentIds",
|
|
"subtreeAgentId",
|
|
"subtreeAgentIds",
|
|
"subtreeRootAgentId",
|
|
"subtreeRootAgentIds",
|
|
]),
|
|
...prefixedScopeValues(grantScope, "subtree:"),
|
|
];
|
|
if (subtreeRootAgentIds.length > 0) {
|
|
constrained = true;
|
|
if (!targetAssigneeAgentId) return false;
|
|
const agentsById = await loadCompanyAgentHierarchy(db, companyId);
|
|
let matchesSubtree = false;
|
|
for (const rootAgentId of subtreeRootAgentIds) {
|
|
if (agentIsInSubtree(agentsById, rootAgentId, targetAssigneeAgentId)) {
|
|
matchesSubtree = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!matchesSubtree) return false;
|
|
}
|
|
|
|
// Unknown metadata keys do not constrain the grant. Recognized constraints
|
|
// return false above when they fail to match the requested assignment scope.
|
|
return !constrained ? true : constrained;
|
|
}
|
|
|
|
function allow(input: Omit<AuthorizationDecision, "allowed">): AuthorizationDecision {
|
|
return { ...input, allowed: true };
|
|
}
|
|
|
|
function deny(input: Omit<AuthorizationDecision, "allowed">): AuthorizationDecision {
|
|
return { ...input, allowed: false };
|
|
}
|
|
|
|
type ResponsibleUserSnapshot = {
|
|
userId: string;
|
|
companyId: string;
|
|
userExists: boolean;
|
|
activeMembership: { companyId: string; membershipRole?: string | null; status?: string } | null;
|
|
};
|
|
|
|
type ResponsibleUserActorWithMemo = AuthorizationActor & {
|
|
__responsibleUserSnapshotMemo?: Map<string, Promise<ResponsibleUserSnapshot>>;
|
|
};
|
|
|
|
const responsibleUserSnapshotCache = new Map<
|
|
string,
|
|
{ expiresAt: number; promise: Promise<ResponsibleUserSnapshot> }
|
|
>();
|
|
|
|
function responsibleUserSnapshotTtlMs() {
|
|
const raw = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_CACHE_TTL_MS?.trim();
|
|
if (!raw) return 5_000;
|
|
const parsed = Number(raw);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 5_000;
|
|
}
|
|
|
|
export function responsibleUserAuthzShadowMode() {
|
|
const mode = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE?.trim().toLowerCase();
|
|
const shadow = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW?.trim().toLowerCase();
|
|
return mode === "shadow" || shadow === "1" || shadow === "true" || shadow === "yes";
|
|
}
|
|
|
|
function activeActorMembership(
|
|
memberships: Array<{ companyId: string; membershipRole?: string | null; status?: string }> | null | undefined,
|
|
companyId: string,
|
|
) {
|
|
return memberships?.find((membership) => membership.companyId === companyId && membership.status === "active") ?? null;
|
|
}
|
|
|
|
function activeResponsibleUserCanAuthorizeIssueAction(
|
|
action: AuthorizationAction,
|
|
membership: ResponsibleUserSnapshot["activeMembership"],
|
|
) {
|
|
return Boolean(
|
|
membership &&
|
|
membership.status === "active" &&
|
|
membership.membershipRole !== "viewer" &&
|
|
(action === "issue:comment" || action === "issue:mutate")
|
|
);
|
|
}
|
|
|
|
function activeResponsibleUserCanAuthorizeAgentGrantedSkillChange(
|
|
action: AuthorizationAction,
|
|
membership: ResponsibleUserSnapshot["activeMembership"],
|
|
agentDecision: AuthorizationDecision,
|
|
actorAgentId: string | null | undefined,
|
|
) {
|
|
return Boolean(
|
|
action === "skill_config:update" &&
|
|
membership &&
|
|
membership.status === "active" &&
|
|
membership.membershipRole !== "viewer" &&
|
|
agentDecision.allowed &&
|
|
(agentDecision.reason === "allow_direct_change" || agentDecision.reason === "allow_consented_change") &&
|
|
agentDecision.grant?.principalType === "agent" &&
|
|
agentDecision.grant.principalId === actorAgentId &&
|
|
(agentDecision.grant.permissionKey === "skills:create" ||
|
|
agentDecision.grant.permissionKey === "skills:suggest-changes"),
|
|
);
|
|
}
|
|
|
|
function scopeBoolean(scope: Record<string, unknown> | null | undefined, key: string) {
|
|
return scope?.[key] === true;
|
|
}
|
|
|
|
export function authorizationDeniedDetails(decision: AuthorizationDecision) {
|
|
return {
|
|
...(decision.code ? { code: decision.code } : {}),
|
|
reason: decision.reason,
|
|
};
|
|
}
|
|
|
|
export function authorizationService(db: Db) {
|
|
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
|
if (!userId) return false;
|
|
if (
|
|
await db
|
|
.select({ id: instanceUserRoles.id })
|
|
.from(instanceUserRoles)
|
|
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
|
.then((rows) => rows[0] ?? null)
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function getActiveMembership(
|
|
companyId: string,
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
) {
|
|
return db
|
|
.select()
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, principalType),
|
|
eq(companyMemberships.principalId, principalId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function loadResponsibleUserSnapshot(companyId: string, userId: string): Promise<ResponsibleUserSnapshot> {
|
|
const [user, membership] = await Promise.all([
|
|
db
|
|
.select({ id: authUsers.id })
|
|
.from(authUsers)
|
|
.where(eq(authUsers.id, userId))
|
|
.then((rows) => rows[0] ?? null),
|
|
db
|
|
.select({
|
|
companyId: companyMemberships.companyId,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
status: companyMemberships.status,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, userId),
|
|
eq(companyMemberships.status, "active"),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null),
|
|
]);
|
|
return {
|
|
userId,
|
|
companyId,
|
|
userExists: Boolean(user),
|
|
activeMembership: user ? membership : null,
|
|
};
|
|
}
|
|
|
|
function getResponsibleUserSnapshot(input: {
|
|
actor: AuthorizationActor;
|
|
companyId: string;
|
|
userId: string;
|
|
}): Promise<ResponsibleUserSnapshot> {
|
|
const actorWithMemo = input.actor as ResponsibleUserActorWithMemo;
|
|
const key = `${input.companyId}:${input.userId}`;
|
|
actorWithMemo.__responsibleUserSnapshotMemo ??= new Map();
|
|
const requestMemo = actorWithMemo.__responsibleUserSnapshotMemo.get(key);
|
|
if (requestMemo) return requestMemo;
|
|
|
|
const actorMembership = input.actor.onBehalfOfUserId === input.userId
|
|
? activeActorMembership(input.actor.onBehalfOfMemberships, input.companyId)
|
|
: null;
|
|
if (actorMembership) {
|
|
const promise = Promise.resolve({
|
|
userId: input.userId,
|
|
companyId: input.companyId,
|
|
userExists: true,
|
|
activeMembership: actorMembership,
|
|
});
|
|
actorWithMemo.__responsibleUserSnapshotMemo.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const cached = responsibleUserSnapshotCache.get(key);
|
|
if (cached && cached.expiresAt > now) {
|
|
actorWithMemo.__responsibleUserSnapshotMemo.set(key, cached.promise);
|
|
return cached.promise;
|
|
}
|
|
|
|
const ttlMs = responsibleUserSnapshotTtlMs();
|
|
const promise = loadResponsibleUserSnapshot(input.companyId, input.userId);
|
|
if (ttlMs > 0) {
|
|
responsibleUserSnapshotCache.set(key, { expiresAt: now + ttlMs, promise });
|
|
promise.catch(() => {
|
|
if (responsibleUserSnapshotCache.get(key)?.promise === promise) {
|
|
responsibleUserSnapshotCache.delete(key);
|
|
}
|
|
});
|
|
}
|
|
actorWithMemo.__responsibleUserSnapshotMemo.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
async function findGrant(
|
|
companyId: string,
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
permissionKey: PermissionKey,
|
|
) {
|
|
return db
|
|
.select()
|
|
.from(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, companyId),
|
|
eq(principalPermissionGrants.principalType, principalType),
|
|
eq(principalPermissionGrants.principalId, principalId),
|
|
eq(principalPermissionGrants.permissionKey, permissionKey),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function decidePrincipalGrant(input: {
|
|
companyId: string;
|
|
principalType: PrincipalType;
|
|
principalId: string;
|
|
action: AuthorizationAction;
|
|
permissionKey: PermissionKey;
|
|
scope?: Record<string, unknown> | null;
|
|
}): Promise<PrincipalGrantDecision> {
|
|
const membership = await getActiveMembership(input.companyId, input.principalType, input.principalId);
|
|
if (!membership) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_missing_membership",
|
|
explanation: `${input.principalType} principal ${input.principalId} is not an active member of company ${input.companyId}.`,
|
|
});
|
|
}
|
|
|
|
const grant = await findGrant(input.companyId, input.principalType, input.principalId, input.permissionKey);
|
|
if (!grant) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_missing_grant",
|
|
explanation: `Missing permission: ${input.permissionKey}.`,
|
|
});
|
|
}
|
|
|
|
if (
|
|
!(await scopeAllows(db, input.companyId, grant.scope, input.scope, {
|
|
requireStructuredScope: input.permissionKey === "tasks:assign_scope",
|
|
}))
|
|
) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_scope",
|
|
explanation: `Permission ${input.permissionKey} does not cover the requested scope.`,
|
|
grant: {
|
|
principalType: input.principalType,
|
|
principalId: input.principalId,
|
|
permissionKey: input.permissionKey,
|
|
scope: grant.scope ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_explicit_grant",
|
|
explanation: `Allowed by explicit grant ${input.permissionKey}.`,
|
|
grant: {
|
|
principalType: input.principalType,
|
|
principalId: input.principalId,
|
|
permissionKey: input.permissionKey,
|
|
scope: grant.scope ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function loadAgent(agentId: string): Promise<AgentAuthorizationRow | null> {
|
|
return db
|
|
.select({
|
|
id: agents.id,
|
|
companyId: agents.companyId,
|
|
role: agents.role,
|
|
status: agents.status,
|
|
reportsTo: agents.reportsTo,
|
|
permissions: agents.permissions,
|
|
})
|
|
.from(agents)
|
|
.where(eq(agents.id, agentId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function loadProject(projectId: string): Promise<ProjectAuthorizationRow | null> {
|
|
return db
|
|
.select({
|
|
id: projects.id,
|
|
companyId: projects.companyId,
|
|
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
|
})
|
|
.from(projects)
|
|
.where(eq(projects.id, projectId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function loadIssue(issueId: string): Promise<IssueAuthorizationRow | null> {
|
|
return db
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
projectId: issues.projectId,
|
|
parentId: issues.parentId,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
checkoutRunId: issues.checkoutRunId,
|
|
status: issues.status,
|
|
executionPolicy: issues.executionPolicy,
|
|
originKind: issues.originKind,
|
|
originId: issues.originId,
|
|
})
|
|
.from(issues)
|
|
.where(eq(issues.id, issueId))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function loadRunPolicy(runId: string | null | undefined, companyId: string, agentId: string) {
|
|
if (!runId) return null;
|
|
const row = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row || row.companyId !== companyId || row.agentId !== agentId) return null;
|
|
const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null;
|
|
return isPlainRecord(context?.executionPolicy)
|
|
? { companyId: row.companyId, executionPolicy: context.executionPolicy }
|
|
: null;
|
|
}
|
|
|
|
async function loadRunIssueId(runId: string | null | undefined, companyId: string, agentId: string) {
|
|
if (!runId) return null;
|
|
const row = await db
|
|
.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row || row.companyId !== companyId || row.agentId !== agentId) return null;
|
|
const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null;
|
|
const issueId = typeof context?.issueId === "string"
|
|
? context.issueId.trim()
|
|
: typeof context?.taskId === "string"
|
|
? context.taskId.trim()
|
|
: "";
|
|
return issueId || null;
|
|
}
|
|
|
|
async function isDirectParentReportTarget(input: {
|
|
actor: AuthorizationActor;
|
|
actorAgentId: string;
|
|
companyId: string;
|
|
resource: AuthorizationResource;
|
|
}) {
|
|
if (input.resource.type !== "issue" || !input.resource.issueId) return false;
|
|
const runIssueId = await loadRunIssueId(input.actor.runId, input.companyId, input.actorAgentId);
|
|
if (!runIssueId || runIssueId === input.resource.issueId) return false;
|
|
const runIssue = await loadIssue(runIssueId);
|
|
return Boolean(
|
|
runIssue &&
|
|
runIssue.companyId === input.companyId &&
|
|
runIssue.assigneeAgentId === input.actorAgentId &&
|
|
runIssue.checkoutRunId === input.actor.runId &&
|
|
runIssue.parentId === input.resource.issueId,
|
|
);
|
|
}
|
|
|
|
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 loadResourceContext(resource: AuthorizationResource) {
|
|
const issue = resource.type === "issue" && resource.issueId ? await loadIssue(resource.issueId) : null;
|
|
const projectId =
|
|
resource.type === "issue"
|
|
? issue?.projectId ?? resource.projectId ?? null
|
|
: resource.type === "project"
|
|
? resource.projectId ?? null
|
|
: null;
|
|
const project = projectId ? await loadProject(projectId) : null;
|
|
return { issue, project };
|
|
}
|
|
|
|
async function resolveActorTrust(input: {
|
|
actorAgent: AgentAuthorizationRow;
|
|
actor: AuthorizationActor;
|
|
companyId: string;
|
|
resource: AuthorizationResource;
|
|
}): Promise<TrustPresetResolution> {
|
|
const { issue, project } = await loadResourceContext(input.resource);
|
|
const run = await loadRunPolicy(input.actor.runId, input.companyId, input.actorAgent.id);
|
|
return resolveCoreTrustPreset({
|
|
companyId: input.companyId,
|
|
agent: input.actorAgent,
|
|
project,
|
|
issue,
|
|
run,
|
|
});
|
|
}
|
|
|
|
async function issueIdIsDescendantOf(issueId: string, rootIssueId: string, companyId: string) {
|
|
const rows = await db.execute(sql`
|
|
WITH RECURSIVE ancestors(id, parent_id, depth) AS (
|
|
SELECT id, parent_id, 0
|
|
FROM issues
|
|
WHERE company_id = ${companyId}
|
|
AND id = ${issueId}
|
|
UNION ALL
|
|
SELECT parent.id, parent.parent_id, ancestors.depth + 1
|
|
FROM issues parent
|
|
JOIN ancestors ON parent.id = ancestors.parent_id
|
|
WHERE parent.company_id = ${companyId}
|
|
AND ancestors.depth < ${LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH - 1}
|
|
)
|
|
SELECT EXISTS(SELECT 1 FROM ancestors WHERE id = ${rootIssueId}) AS is_descendant
|
|
`);
|
|
const first = Array.isArray(rows) ? rows[0] : null;
|
|
return Boolean(
|
|
first &&
|
|
typeof first === "object" &&
|
|
(first as Record<string, unknown>).is_descendant === true,
|
|
);
|
|
}
|
|
|
|
async function issueResourceWithinLowTrustBoundary(
|
|
boundary: LowTrustBoundaryWithCompany,
|
|
resource: Extract<AuthorizationResource, { type: "issue" }>,
|
|
) {
|
|
const issue = resource.issueId ? await loadIssue(resource.issueId) : null;
|
|
const candidate = {
|
|
companyId: resource.companyId,
|
|
id: issue?.id ?? resource.issueId ?? null,
|
|
projectId: issue?.projectId ?? resource.projectId ?? null,
|
|
};
|
|
if (isIssueWithinLowTrustBoundary(boundary, candidate)) return true;
|
|
if (candidate.id && boundary.rootIssueId) {
|
|
return issueIdIsDescendantOf(candidate.id, boundary.rootIssueId, boundary.companyId);
|
|
}
|
|
if (!resource.parentIssueId) return false;
|
|
const parent = await loadIssue(resource.parentIssueId);
|
|
if (!parent) return false;
|
|
if (
|
|
isIssueWithinLowTrustBoundary(boundary, {
|
|
companyId: parent.companyId,
|
|
id: parent.id,
|
|
projectId: parent.projectId,
|
|
})
|
|
) {
|
|
return true;
|
|
}
|
|
return boundary.rootIssueId
|
|
? issueIdIsDescendantOf(parent.id, boundary.rootIssueId, boundary.companyId)
|
|
: false;
|
|
}
|
|
|
|
async function projectWithinLowTrustBoundary(
|
|
boundary: LowTrustBoundaryWithCompany,
|
|
projectId: string | null | undefined,
|
|
) {
|
|
if (!projectId) return false;
|
|
if (boundary.projectIds?.includes(projectId)) return true;
|
|
if (!boundary.rootIssueId) return false;
|
|
const rootIssue = await loadIssue(boundary.rootIssueId);
|
|
return rootIssue?.companyId === boundary.companyId && rootIssue.projectId === projectId;
|
|
}
|
|
|
|
function agentWithinLowTrustBoundary(
|
|
boundary: LowTrustBoundaryWithCompany,
|
|
actorAgentId: string,
|
|
targetAgentId: string | null | undefined,
|
|
) {
|
|
if (!targetAgentId) return false;
|
|
return targetAgentId === actorAgentId || Boolean(boundary.allowedAgentIds?.includes(targetAgentId));
|
|
}
|
|
|
|
async function decideLowTrustAccess(input: {
|
|
actorAgentId: string;
|
|
action: AuthorizationAction;
|
|
resource: AuthorizationResource;
|
|
resolution: TrustPresetResolution;
|
|
directParentReportTarget: boolean;
|
|
}): Promise<AuthorizationDecision | null> {
|
|
if (input.resolution.kind === "standard") return null;
|
|
if (input.resolution.kind === "denied") {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_policy_restricted",
|
|
explanation: input.resolution.detail,
|
|
});
|
|
}
|
|
|
|
const boundary = input.resolution.boundary;
|
|
const lowTrustDeny = (explanation: string) =>
|
|
deny({
|
|
action: input.action,
|
|
reason: "deny_low_trust_boundary",
|
|
explanation,
|
|
});
|
|
const lowTrustAllow = (explanation: string) =>
|
|
allow({
|
|
action: input.action,
|
|
reason: "allow_low_trust_boundary",
|
|
explanation,
|
|
});
|
|
|
|
if (
|
|
input.action === "company_scope:read" ||
|
|
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"
|
|
) {
|
|
return lowTrustDeny(
|
|
`${LOW_TRUST_REVIEW_PRESET} agents cannot use company-wide or privileged ${input.action} APIs by default.`,
|
|
);
|
|
}
|
|
|
|
if (input.action === "agent:read" || input.action === "agent:wake") {
|
|
if (input.resource.type !== "agent") {
|
|
return lowTrustDeny("Low-trust agent action is missing an agent resource.");
|
|
}
|
|
return agentWithinLowTrustBoundary(boundary, input.actorAgentId, input.resource.agentId)
|
|
? lowTrustAllow("Allowed inside the low-trust agent boundary.")
|
|
: lowTrustDeny("Agent is outside this low-trust boundary.");
|
|
}
|
|
|
|
if (input.action === "project:read") {
|
|
const projectId =
|
|
input.resource.type === "issue"
|
|
? input.resource.projectId
|
|
: input.resource.type === "project"
|
|
? input.resource.projectId
|
|
: null;
|
|
return await projectWithinLowTrustBoundary(boundary, projectId)
|
|
? lowTrustAllow("Allowed inside the low-trust project boundary.")
|
|
: lowTrustDeny("Project is outside this low-trust boundary.");
|
|
}
|
|
|
|
if (input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate") {
|
|
if (input.resource.type !== "issue") {
|
|
return lowTrustDeny("Low-trust issue access is missing an issue resource.");
|
|
}
|
|
if (input.action === "issue:comment" && input.directParentReportTarget) {
|
|
if (
|
|
input.resource.issueId &&
|
|
await agentHasMentionGrantOnIssue({
|
|
action: input.action,
|
|
companyId: boundary.companyId,
|
|
issueId: input.resource.issueId,
|
|
issueAssigneeAgentId: input.resource.assigneeAgentId ?? null,
|
|
actorAgentId: input.actorAgentId,
|
|
})
|
|
) {
|
|
return allowIssueMentionGrant(input.action);
|
|
}
|
|
return lowTrustDeny("Direct-parent report comments are disabled for low-trust review runs.");
|
|
}
|
|
if (await issueResourceWithinLowTrustBoundary(boundary, input.resource)) {
|
|
return lowTrustAllow("Allowed inside the low-trust issue boundary.");
|
|
}
|
|
if (
|
|
input.action !== "issue:mutate" &&
|
|
input.resource.issueId &&
|
|
await agentHasMentionGrantOnIssue({
|
|
action: input.action,
|
|
companyId: boundary.companyId,
|
|
issueId: input.resource.issueId,
|
|
issueAssigneeAgentId: input.resource.assigneeAgentId ?? null,
|
|
actorAgentId: input.actorAgentId,
|
|
})
|
|
) {
|
|
return allowIssueMentionGrant(input.action);
|
|
}
|
|
return lowTrustDeny("Issue is outside this low-trust boundary.");
|
|
}
|
|
|
|
if (input.action === "tasks:assign") {
|
|
if (input.resource.type !== "issue") {
|
|
return lowTrustDeny("Low-trust task assignment is missing an issue resource.");
|
|
}
|
|
if (!(await issueResourceWithinLowTrustBoundary(boundary, input.resource))) {
|
|
return lowTrustDeny("Task target is outside this low-trust boundary.");
|
|
}
|
|
if (input.resource.assigneeUserId) {
|
|
return lowTrustDeny("Low-trust agents cannot assign work to board users.");
|
|
}
|
|
if (
|
|
input.resource.assigneeAgentId &&
|
|
!agentWithinLowTrustBoundary(boundary, input.actorAgentId, input.resource.assigneeAgentId)
|
|
) {
|
|
return lowTrustDeny("Assignee agent is outside this low-trust boundary.");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function taskBridgeScopeIds(
|
|
scope: TaskBridgeAgentKeyScope,
|
|
singularKey: "projectId" | "parentIssueId",
|
|
pluralKey: "projectIds" | "parentIssueIds",
|
|
) {
|
|
return [
|
|
...(typeof scope[singularKey] === "string" ? [scope[singularKey]] : []),
|
|
...(Array.isArray(scope[pluralKey]) ? scope[pluralKey] : []),
|
|
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
}
|
|
|
|
async function parentIssueMatchesTaskBridgeBoundary(
|
|
parentIssueId: string | null | undefined,
|
|
companyId: string,
|
|
allowedParentIssueIds: string[],
|
|
) {
|
|
if (!parentIssueId || allowedParentIssueIds.length === 0) return false;
|
|
if (allowedParentIssueIds.includes(parentIssueId)) return true;
|
|
for (const rootIssueId of allowedParentIssueIds) {
|
|
if (await issueIdIsDescendantOf(parentIssueId, rootIssueId, companyId)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function issueMatchesTaskBridgeCreateBoundary(
|
|
scope: TaskBridgeAgentKeyScope,
|
|
resource: Extract<AuthorizationResource, { type: "issue" }>,
|
|
) {
|
|
const allowedProjectIds = taskBridgeScopeIds(scope, "projectId", "projectIds");
|
|
const allowedParentIssueIds = taskBridgeScopeIds(scope, "parentIssueId", "parentIssueIds");
|
|
if (resource.projectId && allowedProjectIds.includes(resource.projectId)) return true;
|
|
if (await parentIssueMatchesTaskBridgeBoundary(resource.parentIssueId, resource.companyId, allowedParentIssueIds)) {
|
|
return true;
|
|
}
|
|
if (resource.parentIssueId && allowedProjectIds.length > 0) {
|
|
const parent = await loadIssue(resource.parentIssueId);
|
|
if (parent?.companyId === resource.companyId && parent.projectId && allowedProjectIds.includes(parent.projectId)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function issueMatchesTaskBridgeWriteBoundary(input: {
|
|
actorAgentId: string;
|
|
keyId: string;
|
|
resource: Extract<AuthorizationResource, { type: "issue" }>;
|
|
}) {
|
|
const issue = input.resource.issueId ? await loadIssue(input.resource.issueId) : null;
|
|
const assigneeAgentId = issue?.assigneeAgentId ?? input.resource.assigneeAgentId ?? null;
|
|
if (assigneeAgentId === input.actorAgentId) return true;
|
|
const originKind = issue?.originKind ?? input.resource.originKind ?? null;
|
|
const originId = issue?.originId ?? input.resource.originId ?? null;
|
|
return originKind === "task_bridge" && originId === input.keyId;
|
|
}
|
|
|
|
async function decideTaskBridgeAccess(input: {
|
|
actorAgentId: string;
|
|
action: AuthorizationAction;
|
|
resource: AuthorizationResource;
|
|
scope: TaskBridgeAgentKeyScope;
|
|
keyId: string;
|
|
}): Promise<AuthorizationDecision | null> {
|
|
const denyBridge = (explanation: string) =>
|
|
deny({
|
|
action: input.action,
|
|
reason: "deny_scope",
|
|
explanation,
|
|
});
|
|
const allowBridge = (explanation: string) =>
|
|
allow({
|
|
action: input.action,
|
|
reason: "allow_explicit_grant",
|
|
explanation,
|
|
});
|
|
|
|
if (
|
|
input.action === "company_scope:read" ||
|
|
input.action === "agent:read" ||
|
|
input.action === "agent:wake" ||
|
|
input.action === "project:read" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read"
|
|
) {
|
|
return denyBridge("Task bridge keys cannot use company-wide, peer-agent, project, runtime, or secret APIs.");
|
|
}
|
|
|
|
if (input.action === "tasks:assign") {
|
|
if (input.resource.type !== "issue") {
|
|
return denyBridge("Task bridge assignment requires an issue resource.");
|
|
}
|
|
if (!(await issueMatchesTaskBridgeCreateBoundary(input.scope, input.resource))) {
|
|
return denyBridge("Task bridge key is outside its approved parent or project boundary.");
|
|
}
|
|
if (input.resource.assigneeUserId) {
|
|
return denyBridge("Task bridge keys cannot assign work to board users.");
|
|
}
|
|
const allowedAssigneeAgentIds = input.scope.allowedAssigneeAgentIds ?? [];
|
|
if (
|
|
input.resource.assigneeAgentId &&
|
|
input.resource.assigneeAgentId !== input.actorAgentId &&
|
|
!allowedAssigneeAgentIds.includes(input.resource.assigneeAgentId)
|
|
) {
|
|
return denyBridge("Task bridge key cannot assign work to that agent.");
|
|
}
|
|
return allowBridge("Allowed by task bridge create boundary.");
|
|
}
|
|
|
|
if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") {
|
|
if (input.resource.type !== "issue") {
|
|
return denyBridge("Task bridge issue access requires an issue resource.");
|
|
}
|
|
return await issueMatchesTaskBridgeWriteBoundary({
|
|
actorAgentId: input.actorAgentId,
|
|
keyId: input.keyId,
|
|
resource: input.resource,
|
|
})
|
|
? allowBridge("Allowed for bridge-created or assigned issue.")
|
|
: denyBridge("Task bridge key can only access assigned or bridge-created issues.");
|
|
}
|
|
|
|
return denyBridge("Task bridge key cannot use this API action.");
|
|
}
|
|
|
|
function decideSkillTestAccess(input: {
|
|
action: AuthorizationAction;
|
|
resource: AuthorizationResource;
|
|
scope: SkillTestAgentKeyScope;
|
|
}): AuthorizationDecision | null {
|
|
const denySkillTest = (explanation: string) =>
|
|
deny({
|
|
action: input.action,
|
|
reason: "deny_scope",
|
|
explanation,
|
|
});
|
|
const allowSkillTest = (explanation: string) =>
|
|
allow({
|
|
action: input.action,
|
|
reason: "allow_explicit_grant",
|
|
explanation,
|
|
});
|
|
|
|
if (
|
|
input.action === "company_scope:read" ||
|
|
input.action === "agent:read" ||
|
|
input.action === "agent:wake" ||
|
|
input.action === "project:read" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read" ||
|
|
input.action === "tasks:assign"
|
|
) {
|
|
return denySkillTest("Skill-test run tokens cannot use company-wide, peer-agent, project, runtime, secret, or task-create APIs.");
|
|
}
|
|
|
|
if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") {
|
|
if (input.resource.type !== "issue") {
|
|
return denySkillTest("Skill-test issue access requires an issue resource.");
|
|
}
|
|
return input.resource.issueId === input.scope.issueId
|
|
? allowSkillTest("Allowed for the scoped skill-test issue.")
|
|
: denySkillTest("Skill-test run token can only access its own harness issue.");
|
|
}
|
|
|
|
return denySkillTest("Skill-test run token cannot use this API action.");
|
|
}
|
|
|
|
async function assignmentTargetIsInCompany(resource: AuthorizationResource) {
|
|
if (resource.type !== "issue") return true;
|
|
if (resource.assigneeAgentId) {
|
|
const target = await loadAgent(resource.assigneeAgentId);
|
|
return Boolean(
|
|
target &&
|
|
target.companyId === resource.companyId &&
|
|
isSimpleAssignableAgentStatus(target.status),
|
|
);
|
|
}
|
|
if (resource.assigneeUserId) {
|
|
return Boolean(await getActiveMembership(resource.companyId, "user", resource.assigneeUserId));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function 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) {
|
|
return isAgentInSubtree(db, companyId, managerAgentId, assigneeAgentId);
|
|
}
|
|
|
|
function commentAuthorCanGrantIssueMention(input: {
|
|
mentionedAgentId: string;
|
|
issueAssigneeAgentId: string | null;
|
|
authorAgentId: string | null;
|
|
authorUserId: string | null;
|
|
activeAuthorUserIds: Set<string>;
|
|
}) {
|
|
if (input.authorAgentId) {
|
|
if (input.authorAgentId === input.mentionedAgentId) return false;
|
|
return input.issueAssigneeAgentId === input.authorAgentId;
|
|
}
|
|
if (input.authorUserId) {
|
|
return input.activeAuthorUserIds.has(input.authorUserId);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function agentHasMentionGrantOnIssue(input: {
|
|
action: AuthorizationAction;
|
|
companyId: string;
|
|
issueId: string;
|
|
issueAssigneeAgentId: string | null;
|
|
actorAgentId: string;
|
|
}) {
|
|
const rows = await db
|
|
.select({
|
|
id: issueComments.id,
|
|
body: issueComments.body,
|
|
authorAgentId: issueComments.authorAgentId,
|
|
authorUserId: issueComments.authorUserId,
|
|
})
|
|
.from(issueComments)
|
|
.where(and(
|
|
eq(issueComments.companyId, input.companyId),
|
|
eq(issueComments.issueId, input.issueId),
|
|
isNull(issueComments.deletedAt),
|
|
sql`${issueComments.body} LIKE ${"%agent://" + input.actorAgentId + "%"}`,
|
|
));
|
|
|
|
const mentionRows = rows.filter((row) => extractAgentMentionIds(row.body).includes(input.actorAgentId));
|
|
const authorUserIds = [...new Set(mentionRows.flatMap((row) => row.authorUserId ? [row.authorUserId] : []))];
|
|
const activeAuthorUserIds = new Set(
|
|
authorUserIds.length === 0
|
|
? []
|
|
: await db
|
|
.select({ principalId: companyMemberships.principalId })
|
|
.from(companyMemberships)
|
|
.where(and(
|
|
eq(companyMemberships.companyId, input.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.status, "active"),
|
|
inArray(companyMemberships.principalId, authorUserIds),
|
|
))
|
|
.then((memberships) => memberships.map((membership) => membership.principalId)),
|
|
);
|
|
|
|
for (const row of mentionRows) {
|
|
const authorCanGrant = commentAuthorCanGrantIssueMention({
|
|
mentionedAgentId: input.actorAgentId,
|
|
issueAssigneeAgentId: input.issueAssigneeAgentId,
|
|
authorAgentId: row.authorAgentId,
|
|
authorUserId: row.authorUserId,
|
|
activeAuthorUserIds,
|
|
});
|
|
if (authorCanGrant) {
|
|
logger.info({
|
|
actorAgentId: input.actorAgentId,
|
|
issueId: input.issueId,
|
|
companyId: input.companyId,
|
|
commentId: row.id,
|
|
grantedAction: input.action,
|
|
grant: "issue_mention_comment",
|
|
}, "authorized issue mention-scoped comment grant");
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function allowIssueMentionGrant(action: AuthorizationAction): AuthorizationDecision {
|
|
return allow({
|
|
action,
|
|
reason: "allow_issue_mention_grant",
|
|
explanation: "Allowed by a mention-scoped issue comment grant.",
|
|
});
|
|
}
|
|
|
|
async function decideBase(input: {
|
|
actor: AuthorizationActor;
|
|
action: AuthorizationAction;
|
|
resource: AuthorizationResource;
|
|
scope?: Record<string, unknown> | null;
|
|
}): Promise<AuthorizationDecision> {
|
|
const permissionKey = permissionForAction(input.action);
|
|
const companyId = companyIdForResource(input.resource);
|
|
|
|
async function decideWithTaskAssignmentGrants(
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
): Promise<AuthorizationDecision> {
|
|
const broadDecision = await decidePrincipalGrant({
|
|
companyId,
|
|
principalType,
|
|
principalId,
|
|
action: input.action,
|
|
permissionKey: "tasks:assign",
|
|
scope: input.scope,
|
|
});
|
|
if (broadDecision.allowed || broadDecision.reason === "deny_missing_membership") return broadDecision;
|
|
const scopedDecision = await decidePrincipalGrant({
|
|
companyId,
|
|
principalType,
|
|
principalId,
|
|
action: input.action,
|
|
permissionKey: "tasks:assign_scope",
|
|
scope: input.scope,
|
|
});
|
|
if (scopedDecision.allowed || broadDecision.reason === "deny_missing_grant") return scopedDecision;
|
|
return broadDecision;
|
|
}
|
|
|
|
async function decideWithAgentConfigReadGrant(
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
): Promise<AuthorizationDecision> {
|
|
const configureDecision = await decidePrincipalGrant({
|
|
companyId,
|
|
principalType,
|
|
principalId,
|
|
action: input.action,
|
|
permissionKey: "agents:configure",
|
|
scope: input.scope,
|
|
});
|
|
if (configureDecision.allowed || configureDecision.reason === "deny_missing_membership") {
|
|
return configureDecision;
|
|
}
|
|
|
|
const suggestDecision = await decidePrincipalGrant({
|
|
companyId,
|
|
principalType,
|
|
principalId,
|
|
action: input.action,
|
|
permissionKey: "agents:suggest-changes",
|
|
scope: input.scope,
|
|
});
|
|
if (suggestDecision.allowed || suggestDecision.reason === "deny_missing_grant") {
|
|
return suggestDecision;
|
|
}
|
|
return configureDecision;
|
|
}
|
|
|
|
async function decideWithProtectedChangeGrants(
|
|
principalType: PrincipalType,
|
|
principalId: string,
|
|
keys: { direct: PermissionKey; suggest: PermissionKey },
|
|
): Promise<AuthorizationDecision> {
|
|
const directDecision = await decidePrincipalGrant({
|
|
companyId,
|
|
principalType,
|
|
principalId,
|
|
action: input.action,
|
|
permissionKey: keys.direct,
|
|
scope: input.scope,
|
|
});
|
|
if (directDecision.allowed) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_direct_change",
|
|
explanation: `Allowed by direct change permission ${keys.direct}.`,
|
|
grant: directDecision.grant,
|
|
});
|
|
}
|
|
if (directDecision.reason === "deny_missing_membership") return directDecision;
|
|
|
|
const suggestDecision = await decidePrincipalGrant({
|
|
companyId,
|
|
principalType,
|
|
principalId,
|
|
action: input.action,
|
|
permissionKey: keys.suggest,
|
|
scope: input.scope,
|
|
});
|
|
if (suggestDecision.allowed) {
|
|
if (scopeBoolean(input.scope, "consentedChange")) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_consented_change",
|
|
explanation: `Allowed by suggest permission ${keys.suggest} after accepted change consent.`,
|
|
grant: suggestDecision.grant,
|
|
});
|
|
}
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_missing_consent",
|
|
explanation: `Permission ${keys.suggest} requires accepted change consent before applying this mutation.`,
|
|
grant: suggestDecision.grant,
|
|
});
|
|
}
|
|
if (suggestDecision.reason === "deny_missing_membership") return suggestDecision;
|
|
if (directDecision.reason === "deny_scope") return directDecision;
|
|
if (suggestDecision.reason === "deny_scope") return suggestDecision;
|
|
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_no_grant",
|
|
explanation: `Missing permission: ${keys.direct} or ${keys.suggest}.`,
|
|
});
|
|
}
|
|
|
|
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,
|
|
reason: "deny_unauthenticated",
|
|
explanation: "Authentication required.",
|
|
});
|
|
}
|
|
|
|
if (input.actor.type === "board") {
|
|
let taskAssignmentPolicyEffect: AssignmentPolicyEffect | null = null;
|
|
if (input.actor.source === "local_implicit") {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_local_board",
|
|
explanation: "Allowed because the actor is the local implicit board.",
|
|
});
|
|
}
|
|
// A cloud_tenant actor's computed `isInstanceAdmin` flag is trusted: it
|
|
// can only be set by the attested trusted-header resolver (stack owner +
|
|
// `enableOwnerInstanceAdmin`). The `instance_user_roles` DB lookup stays
|
|
// excluded for cloud_tenant actors, so a stale or hand-inserted
|
|
// instance_admin row left behind by deployments that ran the
|
|
// pre-hardening cloud_tenant path still elevates nothing.
|
|
if (
|
|
!input.actor.ignoreInstanceAdmin &&
|
|
(input.actor.isInstanceAdmin ||
|
|
(input.actor.source !== "cloud_tenant" && await isInstanceAdmin(input.actor.userId)))
|
|
) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_instance_admin",
|
|
explanation: "Allowed because the actor is an instance admin.",
|
|
});
|
|
}
|
|
// What instance-admin elevation used to give cloud tenant users is
|
|
// replaced by company-scoped visibility: an active membership in the
|
|
// resource company grants the same read surface a same-company agent
|
|
// gets, and non-viewer members may mutate issues inside their company.
|
|
// Cross-company access stays denied.
|
|
if (input.actor.source === "cloud_tenant" && input.actor.userId) {
|
|
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
|
|
if (membership) {
|
|
if (
|
|
input.action === "agent:read" ||
|
|
input.action === "company_scope:read" ||
|
|
input.action === "issue:read" ||
|
|
input.action === "project:read"
|
|
) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_company_member",
|
|
explanation: "Allowed by active cloud tenant company membership.",
|
|
});
|
|
}
|
|
if (
|
|
(input.action === "issue:comment" || input.action === "issue:mutate") &&
|
|
membership.membershipRole !== "viewer"
|
|
) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_company_member",
|
|
explanation: "Allowed by active cloud tenant company membership.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (!input.actor.userId) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_unauthenticated",
|
|
explanation: "Board user id is required.",
|
|
});
|
|
}
|
|
if (input.action === "tasks:assign") {
|
|
if (!(await assignmentTargetIsInCompany(input.resource))) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_company_boundary",
|
|
explanation: "Task assignment target agent is not active in the target company.",
|
|
});
|
|
}
|
|
const policyEffect = await assignmentPolicyEffect(input.resource);
|
|
taskAssignmentPolicyEffect = policyEffect;
|
|
const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect);
|
|
if (policyDeny) return policyDeny;
|
|
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
|
|
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.action === "agent_config:read") {
|
|
return decideWithAgentConfigReadGrant("user", input.actor.userId);
|
|
}
|
|
if (input.action === "agent_config:update") {
|
|
return decideWithProtectedChangeGrants("user", input.actor.userId, {
|
|
direct: "agents:configure",
|
|
suggest: "agents:suggest-changes",
|
|
});
|
|
}
|
|
if (input.action === "skill_config:update") {
|
|
return decideWithProtectedChangeGrants("user", input.actor.userId, {
|
|
direct: "skills:create",
|
|
suggest: "skills:suggest-changes",
|
|
});
|
|
}
|
|
if (!permissionKey) {
|
|
if (
|
|
input.action === "agent:read" ||
|
|
input.action === "company_scope:read" ||
|
|
input.action === "issue:read" ||
|
|
input.action === "project:read" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read"
|
|
) {
|
|
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
|
|
// Mirroring the tasks:assign carve-out above, viewers keep the
|
|
// read-only visibility actions but not the privileged ones.
|
|
const requiresNonViewer =
|
|
input.action === "runtime:manage" || input.action === "secrets:read";
|
|
if (membership && (!requiresNonViewer || membership.membershipRole !== "viewer")) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_simple_company_member",
|
|
explanation: "Allowed by standard same-company board membership visibility.",
|
|
});
|
|
}
|
|
if (membership) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_missing_grant",
|
|
explanation: `Viewer membership does not grant ${input.action}.`,
|
|
});
|
|
}
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_missing_membership",
|
|
explanation: `user principal ${input.actor.userId} is not an active member of company ${companyId}.`,
|
|
});
|
|
}
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_unsupported_action",
|
|
explanation: `No board permission mapping exists for ${input.action}.`,
|
|
});
|
|
}
|
|
if (input.action === "tasks:assign") {
|
|
const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId);
|
|
if (grantDecision.allowed) return grantDecision;
|
|
const policyEffect = taskAssignmentPolicyEffect ?? await assignmentPolicyEffect(input.resource);
|
|
if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect);
|
|
return grantDecision;
|
|
}
|
|
return decidePrincipalGrant({
|
|
companyId,
|
|
principalType: "user",
|
|
principalId: input.actor.userId,
|
|
action: input.action,
|
|
permissionKey,
|
|
scope: input.scope,
|
|
});
|
|
}
|
|
|
|
const actorAgentId = input.actor.agentId ?? null;
|
|
if (!actorAgentId) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_unauthenticated",
|
|
explanation: "Agent authentication required.",
|
|
});
|
|
}
|
|
if (input.actor.companyId !== companyId) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_company_boundary",
|
|
explanation: "Agent key cannot access another company.",
|
|
});
|
|
}
|
|
|
|
const actorAgent = await loadAgent(actorAgentId);
|
|
if (!actorAgent || actorAgent.companyId !== companyId) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_company_boundary",
|
|
explanation: "Actor agent was not found in the target company.",
|
|
});
|
|
}
|
|
|
|
if (input.actor.keyScope?.kind === "skill_test") {
|
|
const skillTestDecision = decideSkillTestAccess({
|
|
action: input.action,
|
|
resource: input.resource,
|
|
scope: input.actor.keyScope,
|
|
});
|
|
if (skillTestDecision) return skillTestDecision;
|
|
}
|
|
|
|
if (input.actor.source === "agent_key" && input.actor.keyScope?.kind === "task_bridge") {
|
|
const keyId = input.actor.keyId ?? null;
|
|
if (!keyId) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_scope",
|
|
explanation: "Task bridge key context is missing.",
|
|
});
|
|
}
|
|
const taskBridgeDecision = await decideTaskBridgeAccess({
|
|
actorAgentId,
|
|
action: input.action,
|
|
resource: input.resource,
|
|
scope: input.actor.keyScope,
|
|
keyId,
|
|
});
|
|
if (taskBridgeDecision) return taskBridgeDecision;
|
|
}
|
|
|
|
const trustResolution = await resolveActorTrust({
|
|
actorAgent,
|
|
actor: input.actor,
|
|
companyId,
|
|
resource: input.resource,
|
|
});
|
|
const directParentReportTarget =
|
|
input.action === "issue:comment" &&
|
|
await isDirectParentReportTarget({
|
|
actor: input.actor,
|
|
actorAgentId,
|
|
companyId,
|
|
resource: input.resource,
|
|
});
|
|
const lowTrustDecision = await decideLowTrustAccess({
|
|
actorAgentId,
|
|
action: input.action,
|
|
resource: input.resource,
|
|
resolution: trustResolution,
|
|
directParentReportTarget,
|
|
});
|
|
if (lowTrustDecision) {
|
|
if (!lowTrustDecision.allowed) return lowTrustDecision;
|
|
if (
|
|
input.action === "agent:read" ||
|
|
input.action === "agent:wake" ||
|
|
input.action === "company_scope:read" ||
|
|
input.action === "issue:comment" ||
|
|
input.action === "issue:read" ||
|
|
input.action === "project:read" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read"
|
|
) {
|
|
return lowTrustDecision;
|
|
}
|
|
}
|
|
|
|
if (
|
|
trustResolution.kind === "standard" &&
|
|
input.action === "issue:comment" &&
|
|
directParentReportTarget
|
|
) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_direct_parent_report",
|
|
explanation: "Allowed because the target is the current run issue's direct parent under the standard trust preset.",
|
|
});
|
|
}
|
|
|
|
|
|
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.",
|
|
inboxPolicyMode: "grant_override",
|
|
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",
|
|
inboxPolicyMode: policy?.mode ?? "open",
|
|
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" ||
|
|
input.action === "issue:read" ||
|
|
input.action === "project:read" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read"
|
|
) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_company_agent",
|
|
explanation: "Allowed by standard same-company agent visibility.",
|
|
});
|
|
}
|
|
|
|
if (input.action === "agent:wake" && input.resource.type === "agent" && input.resource.agentId === actorAgentId) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_self",
|
|
explanation: "Allowed because the actor is waking itself.",
|
|
});
|
|
}
|
|
|
|
if (input.action === "tasks:assign") {
|
|
if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_missing_membership",
|
|
explanation: "Actor agent is not active for simple mode task assignment.",
|
|
});
|
|
}
|
|
if (!(await assignmentTargetIsInCompany(input.resource))) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_company_boundary",
|
|
explanation: "Task assignment target agent is not active in the target company.",
|
|
});
|
|
}
|
|
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",
|
|
explanation: "Allowed by simple mode company-wide task assignment default.",
|
|
});
|
|
}
|
|
|
|
if (input.action === "issue:comment" || input.action === "issue:mutate") {
|
|
const resource = input.resource.type === "issue" ? input.resource : null;
|
|
if (resource?.assigneeAgentId === actorAgentId) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_self",
|
|
explanation: "Allowed because the actor owns the assigned issue.",
|
|
});
|
|
}
|
|
if (!resource?.assigneeAgentId) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_company_agent",
|
|
explanation: "Allowed because the issue has no agent assignee.",
|
|
});
|
|
}
|
|
if (
|
|
input.action === "issue:comment" &&
|
|
resource?.issueId &&
|
|
await agentHasMentionGrantOnIssue({
|
|
action: input.action,
|
|
companyId,
|
|
issueId: resource.issueId,
|
|
issueAssigneeAgentId: resource.assigneeAgentId ?? null,
|
|
actorAgentId,
|
|
})
|
|
) {
|
|
return allowIssueMentionGrant(input.action);
|
|
}
|
|
}
|
|
if (
|
|
input.action === "agent_config:update" &&
|
|
input.resource.type === "agent" &&
|
|
input.resource.agentId === actorAgentId &&
|
|
!scopeBoolean(input.scope, "requiresChangeGrant")
|
|
) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_self",
|
|
explanation: "Allowed because the actor is updating its own agent configuration.",
|
|
});
|
|
}
|
|
|
|
if (input.action === "agent_config:read") {
|
|
if (input.resource.type === "agent" && input.resource.agentId === actorAgentId) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_self",
|
|
explanation: "Allowed because the actor is reading its own agent configuration.",
|
|
});
|
|
}
|
|
return decideWithAgentConfigReadGrant("agent", actorAgentId);
|
|
}
|
|
|
|
if (input.action === "agent_config:update") {
|
|
return decideWithProtectedChangeGrants("agent", actorAgentId, {
|
|
direct: "agents:configure",
|
|
suggest: "agents:suggest-changes",
|
|
});
|
|
}
|
|
|
|
if (input.action === "skill_config:update") {
|
|
return decideWithProtectedChangeGrants("agent", actorAgentId, {
|
|
direct: "skills:create",
|
|
suggest: "skills:suggest-changes",
|
|
});
|
|
}
|
|
|
|
if (permissionKey) {
|
|
const grantDecision = await decidePrincipalGrant({
|
|
companyId,
|
|
principalType: "agent",
|
|
principalId: actorAgentId,
|
|
action: input.action,
|
|
permissionKey,
|
|
scope: input.scope,
|
|
});
|
|
if (grantDecision.allowed) return grantDecision;
|
|
}
|
|
|
|
if (
|
|
(input.action === "agents:create" ||
|
|
input.action === "tasks:manage_active_checkouts") &&
|
|
canCreateAgentsLegacy(actorAgent)
|
|
) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_legacy_agent_creator",
|
|
explanation: "Allowed by legacy agent creator authority.",
|
|
});
|
|
}
|
|
|
|
if (
|
|
input.action === "tasks:manage_active_checkouts" &&
|
|
input.resource.type === "issue" &&
|
|
input.resource.assigneeAgentId &&
|
|
await isManagerOf(companyId, actorAgentId, input.resource.assigneeAgentId)
|
|
) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_manager_chain",
|
|
explanation: "Allowed because the actor manages the issue assignee in the reporting chain.",
|
|
});
|
|
}
|
|
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_missing_grant",
|
|
explanation: permissionKey
|
|
? `Missing permission: ${permissionKey}.`
|
|
: `No agent permission mapping exists for ${input.action}.`,
|
|
});
|
|
}
|
|
|
|
async function applyResponsibleUserIntersection(
|
|
input: {
|
|
actor: AuthorizationActor;
|
|
action: AuthorizationAction;
|
|
resource: AuthorizationResource;
|
|
scope?: Record<string, unknown> | null;
|
|
},
|
|
agentDecision: AuthorizationDecision,
|
|
): Promise<AuthorizationDecision> {
|
|
const responsibleUserId = input.actor.onBehalfOfUserId?.trim();
|
|
if (
|
|
input.actor.type !== "agent" ||
|
|
input.action === "inbox:manage" ||
|
|
!responsibleUserId ||
|
|
!agentDecision.allowed
|
|
) {
|
|
return agentDecision;
|
|
}
|
|
|
|
const companyId = companyIdForResource(input.resource);
|
|
const snapshot = await getResponsibleUserSnapshot({
|
|
actor: input.actor,
|
|
companyId,
|
|
userId: responsibleUserId,
|
|
});
|
|
const denyCode: AuthorizationDecision["code"] =
|
|
snapshot.userExists && snapshot.activeMembership
|
|
? "RESPONSIBLE_USER_UNAUTHORIZED"
|
|
: "RESPONSIBLE_USER_UNAVAILABLE";
|
|
|
|
if (
|
|
activeResponsibleUserCanAuthorizeAgentGrantedSkillChange(
|
|
input.action,
|
|
snapshot.activeMembership,
|
|
agentDecision,
|
|
input.actor.agentId,
|
|
)
|
|
) {
|
|
// Skill mutations are governed by the agent's explicit skill-change
|
|
// grant. The responsible-user intersection still requires an active
|
|
// non-viewer user, but does not require duplicating that grant on the
|
|
// responsible user's board account for standard heartbeat JWTs.
|
|
return agentDecision;
|
|
}
|
|
|
|
const userDecision = snapshot.userExists && snapshot.activeMembership
|
|
? await decideBase({
|
|
...input,
|
|
actor: {
|
|
type: "board",
|
|
userId: responsibleUserId,
|
|
companyIds: [companyId],
|
|
memberships: [snapshot.activeMembership],
|
|
isInstanceAdmin: false,
|
|
ignoreInstanceAdmin: true,
|
|
source: "session",
|
|
},
|
|
})
|
|
: deny({
|
|
action: input.action,
|
|
reason: "deny_missing_membership",
|
|
explanation: `Responsible user ${responsibleUserId} is unavailable for company ${companyId}.`,
|
|
});
|
|
|
|
if (
|
|
!userDecision.allowed &&
|
|
userDecision.reason === "deny_unsupported_action" &&
|
|
activeResponsibleUserCanAuthorizeIssueAction(input.action, snapshot.activeMembership)
|
|
) {
|
|
return agentDecision;
|
|
}
|
|
|
|
if (userDecision.allowed) return agentDecision;
|
|
|
|
const denied = deny({
|
|
action: input.action,
|
|
reason: userDecision.reason,
|
|
code: denyCode,
|
|
explanation:
|
|
denyCode === "RESPONSIBLE_USER_UNAVAILABLE"
|
|
? `Responsible user ${responsibleUserId} is unavailable for company ${companyId}.`
|
|
: `Responsible user ${responsibleUserId} is not authorized for ${input.action}: ${userDecision.explanation}`,
|
|
grant: userDecision.grant,
|
|
});
|
|
|
|
logger.warn({
|
|
authzMode: responsibleUserAuthzShadowMode() ? "shadow" : "enforce",
|
|
code: denied.code,
|
|
reason: userDecision.reason,
|
|
action: input.action,
|
|
resourceType: input.resource.type,
|
|
companyId,
|
|
actorAgentId: input.actor.agentId ?? null,
|
|
responsibleUserId,
|
|
}, "responsible-user authorization intersection denied");
|
|
|
|
return responsibleUserAuthzShadowMode() ? agentDecision : denied;
|
|
}
|
|
|
|
async function decide(input: {
|
|
actor: AuthorizationActor;
|
|
action: AuthorizationAction;
|
|
resource: AuthorizationResource;
|
|
scope?: Record<string, unknown> | null;
|
|
}): Promise<AuthorizationDecision> {
|
|
const agentDecision = await decideBase(input);
|
|
return applyResponsibleUserIntersection(input, agentDecision);
|
|
}
|
|
|
|
return {
|
|
decide,
|
|
decidePrincipalGrant,
|
|
};
|
|
}
|