mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Agent creation, approval, pause, and termination change whether an agent can work. > - These changes currently use separate service and database paths. > - Some agents need setup after their record exists and before their harness can run. > - This PR adds one module that owns those state changes and their transactions. > - The module records progress and retries incomplete work after a server restart. ## Linked Issues or Issue Description **Problem or motivation** A hire can become active before its execution environment is ready. Direct agent state writes also bypass one common lifecycle boundary. Termination does not have a durable step for resource cleanup. **Proposed solution** Use agent lifecycle commands for hiring, approval, pause, resume, and termination. Keep proposed hires pending until approval. Prepare each approved hire, test its saved harness configuration, and then permit work. Wait for required plugin work before completing a step. **Alternatives considered** A general database commit hook would expose transaction details to callers. A timer alone would delay each new hire. This module owns each command transaction and starts its worker after commit. A periodic scan recovers work after a process stops. **Roadmap alignment** This change adds the lifecycle boundary for the existing agent and plugin systems. It does not add a user interface or a provider implementation. It replaces the lifecycle delivery approach in #15391. Related proposals include #11475 and #13099; this change does not add their external executor protocols. ## What Changed - Add durable approval, preparation, verification, pause, resume, stop, and cleanup states. - Keep agent creation, lifecycle state, configuration writes, and agent record deletion behind `modules/agent-lifecycle`. Keep validation, credential rules, reads, onboarding, invitations, general approvals, and company data deletion in their services. - Commit a hire before the remaining onboarding or invitation work. Serialize requests with the existing dedicated database connection helper. Reuse the hire after a failed attempt. - Keep accepted work in the run queue during setup. Require execution and cleanup evidence before completing pause or termination. - Retain the original credential owner when approving migrated hires. - Start lifecycle work after each command commits. Use database leases and revision checks for retries and late results. - Add a generic required plugin method and capability. Store the required plugin IDs for each agent. Track host and plugin completion separately. - Keep harness verification and credential rules in services. Supply their results to lifecycle through explicit ports. Reject failed logins and incomplete probes. - Remove reverse imports from lifecycle adapters into services. Share record queries and validation below both layers. - Use one hire decision path for agent IDs and approval IDs. Resolve the approval and agent change in one transaction. Expose policy reconciliation on the command object. - Save configuration and verification invalidation through one module operation. Ordinary commands own their transaction. Restrict the shared transaction integration to its listed callers. Reject identity, company, accounting, timestamp, and unknown fields. - Require completed termination before deleting agent records. The company deletion service calls the module through a separate deletion interface. - Save an approved secret before applying its agent binding. A failed binding leaves the secret approved. A retry reuses it. - Remove old permission and metadata export files. Move hire configuration rules out of lifecycle persistence. - Report failed worker steps and scans with operation context and known error codes. Omit provider messages, stacks, and configuration values. Document ordinary application use and extension rules. - Recover expired native owners only after execution stops. Retry cleanup of failed retained leases. Wait for setup in the live smoke script. - Keep manual, budget, and company pause reasons separate. Guard execution status writes with the ready state. - Add a migration, boundary checks, regression tests, and a lifecycle guide. Keep the current UI. - Limit periodic policy checks to 100 eligible agents per minute. Continue from the last agent ID. Exclude pending hires and agents in termination or a final state. Keep explicit company and budget updates immediate. ## Verification - Latest head `ccc3c86b0` aligns the shared status update schema with the PATCH route. All 15 shared tests, 104 server route/OpenAPI tests, 2 CLI tests, and shared/server/CLI TypeScript checks pass. The Greptile comment is fixed. CI is running; no new Apex review was requested for this small fix. - Rebase `75708ac94` rebases onto master at `2eb5187e4`. The backup test uses the shared 90-second timeout from master; the older timeout commit was removed. The affected backup test passes. - Agent budget edits now pass the target agent ID through enforcement. All 50 affected tests, server TypeScript, and 11 boundary tests pass. Both configuration update paths have regression coverage. CI is running on the latest head. No new Apex review was requested for these small fixes. - Rebase `9b568666a` preserves the run-control extraction and delivery-work startup changes from master. All 559 affected tests, server TypeScript, and 11 boundary tests pass. CI and Apex review are running on the final head. - The policy scan fix passes 56 lifecycle and diagnostic tests, 24 policy and caller tests, and 11 boundary tests. A regression test checks the page limit, interval, excluded states, and cursor wraparound. - Rebase `fd4698750` passes all 611 tests in eight affected lifecycle, route, and heartbeat suites. Server TypeScript and all 11 module boundary tests pass. The company deletion rollback and concurrent hire tests are included. - Follow-up `00aac7646` fixes the failed decision deletion test and the public update schema. All 126 affected server tests, 8 shared tests, 2 CLI tests, and 11 boundary tests pass. Shared, server, CLI, and UI TypeScript checks pass. - Server TypeScript passes: `pnpm --filter @paperclipai/server exec tsc --noEmit`. - All 11 boundary tests pass: `node --test scripts/check-module-boundaries.test.mjs`. The repository boundary scan also passes. - All 68 lifecycle, failure diagnostic, and harness verification tests pass. Tests reject forbidden fields through the root command, combined transition command, and restricted transaction integration. They also check invalid fields returned by configuration preparation. - Failure tests cover host work, plugin work, claims, renewals, deferrals, and scans. Log tests check known error codes and exclusion of private error contents. - All 355 tests in 10 existing caller suites pass. These tests cover secret approval, pairing, pending hires, credentials, company import, built-in agents, managed agents, instructions, and skills. - The branch is rebased onto `origin/master` at `2eb5187e4`. The queue guards and cancellation lock fix use the extracted heartbeat queue. Agent cancellation uses the extracted run-control file. The source diff scan found no secrets or private URLs. - Earlier checks cover approval retries, rollback, late verification results, policy holds, deletion rollback, concurrent company deletion, and queue admission during setup. - Before the rebase, CI passed on `00aac7646` and Greptile Apex rated it 5/5 with no open comments. CI and a new Apex review are pending for the rebased commit. The branch has no merge conflicts. - The full local build and native Dot suite were not run. The host has no Cargo. No live provider test was run. ## Risks - Background policy recovery takes more than one minute when more than 100 agents are eligible. Explicit company and budget updates still reconcile immediately. - The restricted configuration integration keeps an agent reference and its dependent records in one transaction. It cannot change a budget or invoke a lifecycle transition. A periodic scan starts pending setup after that transaction commits. - Secret approval and binding use separate transactions. A failed binding can leave an approved secret with no agent binding. - New approved hires remain unavailable until setup and verification complete. Existing clients see `paused` during these steps. This PR does not change the UI. - Migration `0320_daily_onslaught.sql` maps existing statuses and pause reasons. It does not create external resources or claim that an existing harness passed verification. - Use a coordinated server update for this migration. Older server versions can write legacy status without the new lifecycle checks. - A failed onboarding or invitation request can leave a committed hire. A retry reuses that hire and completes the remaining records. - Deletion requires completed termination. Company deletion can require another request while cleanup is pending. - Required plugins must make repeated calls safe and reject older revisions. A disabled or removed required plugin blocks completion until it is restored. - A new hire can run a second harness test after the existing UI test. Some adapters can charge for this test. - No live provider test was run. Provider implementation and resource backfill remain separate work. ## Model Used OpenAI GPT-6 through Codex, with reasoning, code tools, and local test execution. ## 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 described the issue in this PR with the feature request field labels - [x] I have not referenced internal or instance-local issues or links - [x] My branch name describes the change - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] Targeted local tests pass - [x] All Paperclip CI gates are green on the new commit - [x] Greptile Apex is 5/5 with no open comments on the new commit - [x] I will address review comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2814 lines
105 KiB
TypeScript
2814 lines
105 KiB
TypeScript
import { and, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { withHumanDirectedWork } from "./human-directed-work.js";
|
|
import { hasOwnerChatInstructionAuthority } from "./owner-chat-instruction-authority.js";
|
|
import {
|
|
agents,
|
|
approvals,
|
|
issueApprovals,
|
|
authUsers,
|
|
companyMemberships,
|
|
chatConversations,
|
|
chatDeliveries,
|
|
chatIdentityLinks,
|
|
heartbeatRuns,
|
|
executionWorkspaces,
|
|
instanceUserRoles,
|
|
issueComments,
|
|
issueAccessGrants,
|
|
issues,
|
|
principalPermissionGrants,
|
|
projectAccessMembers,
|
|
projects,
|
|
userInboxAgentPolicies,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
AgentApiKeyScope,
|
|
InboxAgentPolicyMode,
|
|
PermissionKey,
|
|
PrincipalType,
|
|
SkillTestAgentKeyScope,
|
|
TaskBridgeAgentKeyScope,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
LOW_TRUST_REVIEW_PRESET,
|
|
extractAgentMentionIds,
|
|
trustPresetSchema,
|
|
lowTrustBoundarySchema,
|
|
lowTrustReviewPresetPolicySchema,
|
|
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";
|
|
import { normalizeAgentPermissions } from "../lib/agent-permissions.js";
|
|
import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.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"
|
|
| "mcp_oauth"
|
|
| "cloud_tenant"
|
|
| "cloud_control"
|
|
| "none";
|
|
};
|
|
|
|
export type AuthorizationAction =
|
|
| PermissionKey
|
|
| "agent_config:read"
|
|
| "agent_config:update"
|
|
| "agent_instructions:update"
|
|
| "skill_config:update"
|
|
| "agent:read"
|
|
| "agent:wake"
|
|
| "company_scope:read"
|
|
| "decision_queue:manage"
|
|
| "decision_queue:read"
|
|
| "decision_triage:manage"
|
|
| "issue:comment"
|
|
| "issue:mutate"
|
|
| "issue:read"
|
|
| "project:read"
|
|
| "runtime:manage"
|
|
| "secrets:read"
|
|
| "secrets:propose";
|
|
|
|
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_role_default"
|
|
| "allow_user_inbox_policy"
|
|
| "allow_direct_change"
|
|
| "allow_consented_change"
|
|
| "allow_legacy_agent_creator"
|
|
| "allow_issue_mention_grant"
|
|
| "allow_direct_parent_report"
|
|
| "allow_visible_issue_write"
|
|
| "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_issue_private"
|
|
| "deny_project_private"
|
|
| "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 === "agent_instructions:update" || action === "skill_config:update") {
|
|
return null;
|
|
}
|
|
if (
|
|
action === "agent:read" ||
|
|
action === "agent:wake" ||
|
|
action === "company_scope:read" ||
|
|
action === "decision_queue:manage" ||
|
|
action === "decision_queue:read" ||
|
|
action === "decision_triage:manage" ||
|
|
action === "issue:read" ||
|
|
action === "project:read" ||
|
|
action === "runtime:manage" ||
|
|
action === "secrets:read" ||
|
|
action === "secrets:propose"
|
|
) {
|
|
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;
|
|
// Raw agent rows may predate permission normalization; apply the same
|
|
// defaults the agent service applies on read so enforcement matches what
|
|
// the API reports.
|
|
return normalizeAgentPermissions(agent.permissions).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: "blocked"; 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");
|
|
// Core containment policy is understood independently of assignment policy.
|
|
// Recognizing it must not hide malformed values or unknown extension rules.
|
|
const trustSections = [
|
|
["trustPreset", trustPresetSchema],
|
|
["trustBoundary", lowTrustBoundarySchema],
|
|
["reviewPreset", lowTrustReviewPresetPolicySchema],
|
|
] as const;
|
|
const hasTrustPolicy = trustSections.some(([key]) => policy[key] !== undefined);
|
|
const hasInvalidTrustPolicy = trustSections.some(([key, schema]) =>
|
|
policy[key] !== undefined && !schema.safeParse(policy[key]).success,
|
|
);
|
|
const knownTopLevelKeys = new Set([
|
|
"agentVisibility",
|
|
"assignmentPolicy",
|
|
"protectedAgent",
|
|
"managedBy",
|
|
...trustSections.map(([key]) => key),
|
|
]);
|
|
const hasUnknownTopLevelKey = Object.keys(policy).some((key) => !knownTopLevelKeys.has(key));
|
|
const hasKnownPolicySection = Boolean(agentVisibility || assignmentPolicy || protectedAgent || hasTrustPolicy);
|
|
if (hasUnknownTopLevelKey || hasInvalidTrustPolicy || !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.`,
|
|
};
|
|
}
|
|
|
|
// `requiresApproval` and `protectedAgentRequiresApproval` are legacy aliases.
|
|
// They never had an approval workflow behind them, so preserve their hard-block
|
|
// behavior without continuing to promise an approval step that does not exist.
|
|
const blockAssignment =
|
|
readBoolean(protectedAgent?.blockAssignment) === true ||
|
|
readBoolean(protectedAgent?.requiresApproval) === true ||
|
|
readBoolean(assignmentPolicy?.protectedAgentRequiresApproval) === true;
|
|
if (blockAssignment) {
|
|
return {
|
|
kind: "blocked",
|
|
explanation:
|
|
`${label} assignment is blocked by protected-agent policy. ` +
|
|
"A company administrator can remove the assignment block, then retry.",
|
|
};
|
|
}
|
|
|
|
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 | DbTransaction, 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 | DbTransaction,
|
|
companyId: string,
|
|
rootAgentId: string,
|
|
targetAgentId: string,
|
|
) {
|
|
return agentIsInSubtree(
|
|
await loadCompanyAgentHierarchy(db, companyId),
|
|
rootAgentId,
|
|
targetAgentId,
|
|
);
|
|
}
|
|
|
|
async function scopeAllows(
|
|
db: Db | DbTransaction,
|
|
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> }
|
|
>();
|
|
|
|
export type IssuePrivacyRow = {
|
|
id: string;
|
|
companyId: string;
|
|
visibility: string;
|
|
privacyRootIssueId: string | null;
|
|
responsibleUserId: string | null;
|
|
createdByUserId: string | null;
|
|
assigneeUserId: string | null;
|
|
assigneeAgentId: string | null;
|
|
projectId?: string | null;
|
|
};
|
|
|
|
export type ProjectPrivacyRow = {
|
|
id: string;
|
|
companyId: string;
|
|
visibility: string;
|
|
};
|
|
|
|
export function issuePrivacyMode(): "off" | "shadow" | "enforce" {
|
|
const mode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE?.trim().toLowerCase();
|
|
if (mode === "off" || mode === "shadow") return mode;
|
|
return "enforce";
|
|
}
|
|
|
|
function issuePrivacyPrincipal(actor: AuthorizationActor) {
|
|
if (actor.type === "board" && actor.userId) return { type: "user" as const, id: actor.userId };
|
|
if (actor.type === "agent" && actor.agentId) return { type: "agent" as const, id: actor.agentId };
|
|
return null;
|
|
}
|
|
|
|
function projectPrivacyPrincipalCondition(actor: AuthorizationActor): SQL<boolean> {
|
|
const principal = issuePrivacyPrincipal(actor);
|
|
return sql<boolean>`(${projects.visibility} = 'open' or ${principal ? sql`exists (
|
|
select 1 from ${projectAccessMembers} m where m.company_id = ${projects.companyId}
|
|
and m.project_id = ${projects.id} and m.subject_type = ${principal.type}
|
|
and m.subject_id = ${principal.id})` : sql`false`})`;
|
|
}
|
|
|
|
function projectPrivacyCondition(actor: AuthorizationActor): SQL<boolean> {
|
|
const own = projectPrivacyPrincipalCondition(actor);
|
|
if (actor.type !== "agent" || !actor.onBehalfOfUserId) return own;
|
|
return sql<boolean>`(${own} and ${projectPrivacyPrincipalCondition({ type: "board", userId: actor.onBehalfOfUserId })}
|
|
and exists (select 1 from ${companyMemberships} m where m.company_id = ${projects.companyId}
|
|
and m.principal_type = 'user' and m.principal_id = ${actor.onBehalfOfUserId} and m.status = 'active'))`;
|
|
}
|
|
|
|
export async function canActorReadProjectPrivacy(db: Db | DbTransaction, actor: AuthorizationActor, project: ProjectPrivacyRow) {
|
|
return db.select({ id: projects.id }).from(projects)
|
|
.where(and(eq(projects.id, project.id), eq(projects.companyId, project.companyId), projectPrivacyCondition(actor)))
|
|
.limit(1).then(rows => rows.length > 0);
|
|
}
|
|
|
|
/** One predicate for direct reads and SQL pushdown. Grants flow only from a task
|
|
* to its privacy descendants, never from a child to its ancestors or siblings.
|
|
* UNION bounds traversal even if historical data contains a cycle. */
|
|
function issuePrivacyPrincipalCondition(actor: AuthorizationActor): SQL<boolean> {
|
|
const principal = issuePrivacyPrincipal(actor);
|
|
const implicit = principal?.type === "agent"
|
|
? sql`n.assignee_agent_id = ${principal.id}`
|
|
: principal?.type === "user"
|
|
? sql`(n.responsible_user_id = ${principal.id} or n.created_by_user_id = ${principal.id} or n.assignee_user_id = ${principal.id})`
|
|
: sql`false`;
|
|
const grant = principal ? sql`exists (
|
|
select 1 from ${issueAccessGrants} g where g.issue_id = n.id
|
|
and g.subject_type = ${principal.type} and g.subject_id = ${principal.id}
|
|
and g.revoked_at is null
|
|
)` : sql`false`;
|
|
const projectMember = principal ? sql`exists (
|
|
select 1 from ${projectAccessMembers} m
|
|
join ${projects} p on p.id = m.project_id and p.company_id = m.company_id
|
|
where p.id = n.project_id and p.company_id = n.company_id and p.visibility = 'private'
|
|
and m.subject_type = ${principal.type} and m.subject_id = ${principal.id}
|
|
)` : sql`false`;
|
|
return sql<boolean>`exists (
|
|
with recursive privacy_ancestors as (
|
|
select p.id, p.company_id, p.privacy_parent_issue_id, p.visibility, p.project_id,
|
|
p.responsible_user_id, p.created_by_user_id, p.assignee_user_id, p.assignee_agent_id
|
|
from issues p where p.id = ${issues.id} and p.company_id = ${issues.companyId}
|
|
union
|
|
select p.id, p.company_id, p.privacy_parent_issue_id, p.visibility, p.project_id,
|
|
p.responsible_user_id, p.created_by_user_id, p.assignee_user_id, p.assignee_agent_id
|
|
from issues p join privacy_ancestors c on p.id = c.privacy_parent_issue_id and p.company_id = c.company_id
|
|
where p.visibility = 'private' or exists (select 1 from projects pp where pp.id = p.project_id and pp.company_id = p.company_id and pp.visibility = 'private')
|
|
)
|
|
select 1 from privacy_ancestors n
|
|
where ${implicit} or ${grant} or ${projectMember}
|
|
or not exists (
|
|
select 1 from privacy_ancestors restricted
|
|
left join projects private_project on private_project.id = restricted.project_id
|
|
and private_project.company_id = restricted.company_id
|
|
where restricted.visibility = 'private' or private_project.visibility = 'private'
|
|
)
|
|
)`;
|
|
}
|
|
|
|
function issuePrivacyCondition(actor: AuthorizationActor): SQL<boolean> {
|
|
const principalCondition = issuePrivacyPrincipalCondition(actor);
|
|
if (actor.type !== "agent" || !actor.onBehalfOfUserId) return principalCondition;
|
|
const userId = actor.onBehalfOfUserId;
|
|
// A shared agent's grants cannot amplify the user whose run is making the call.
|
|
return sql<boolean>`(${principalCondition} and ${issuePrivacyPrincipalCondition({ type: "board", userId })}
|
|
and exists (select 1 from ${companyMemberships} m where m.company_id = ${issues.companyId}
|
|
and m.principal_type = 'user' and m.principal_id = ${userId} and m.status = 'active'))`;
|
|
}
|
|
|
|
export async function canActorReadIssuePrivacy(db: Db | DbTransaction, actor: AuthorizationActor, issue: IssuePrivacyRow) {
|
|
return db.select({ id: issues.id }).from(issues)
|
|
.where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId), issuePrivacyCondition(actor)))
|
|
.limit(1).then(rows => rows.length > 0);
|
|
}
|
|
|
|
/** Filter every externally reachable task query with the same privacy predicate. */
|
|
export async function issueReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
|
if (issuePrivacyMode() !== "enforce") return sql<boolean>`true`;
|
|
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql<boolean>`true`;
|
|
if (actor.type === "board" && !actor.ignoreInstanceAdmin && actor.source !== "cloud_tenant"
|
|
&& await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles)
|
|
.where(and(eq(instanceUserRoles.userId, actor.userId ?? ""), eq(instanceUserRoles.role, "instance_admin")))
|
|
.limit(1).then(rows => rows.length > 0)) return sql<boolean>`true`;
|
|
return issuePrivacyCondition(actor);
|
|
}
|
|
|
|
/** SQL equivalent used for project list/search pushdown. */
|
|
export async function projectReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
|
if (issuePrivacyMode() !== "enforce") return sql<boolean>`true`;
|
|
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql<boolean>`true`;
|
|
if (
|
|
actor.type === "board"
|
|
&& !actor.ignoreInstanceAdmin
|
|
&& actor.source !== "cloud_tenant"
|
|
&& Boolean(await db
|
|
.select({ id: instanceUserRoles.id })
|
|
.from(instanceUserRoles)
|
|
.where(and(
|
|
eq(instanceUserRoles.userId, actor.userId ?? ""),
|
|
eq(instanceUserRoles.role, "instance_admin"),
|
|
))
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null))
|
|
) return sql<boolean>`true`;
|
|
return projectPrivacyCondition(actor);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
|
|
export function authorizationService(db: Db | DbTransaction) {
|
|
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 promise = loadResponsibleUserSnapshot(input.companyId, input.userId);
|
|
actorWithMemo.__responsibleUserSnapshotMemo.set(key, promise);
|
|
void promise.catch(() => {
|
|
if (actorWithMemo.__responsibleUserSnapshotMemo?.get(key) === promise) {
|
|
actorWithMemo.__responsibleUserSnapshotMemo.delete(key);
|
|
}
|
|
});
|
|
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) {
|
|
if (
|
|
input.principalType === "user"
|
|
&& input.permissionKey.startsWith("tools:")
|
|
&& (membership.membershipRole === "owner" || membership.membershipRole === "admin")
|
|
&& grantsForHumanRole(normalizeHumanRole(membership.membershipRole, "operator"))
|
|
.some((defaultGrant) => defaultGrant.permissionKey === input.permissionKey)
|
|
) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_role_default",
|
|
explanation: `Allowed by the ${membership.membershipRole ?? "operator"} membership role.`,
|
|
});
|
|
}
|
|
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);
|
|
const resolution = resolveCoreTrustPreset({
|
|
companyId: input.companyId,
|
|
agent: input.actorAgent,
|
|
project,
|
|
issue,
|
|
run,
|
|
});
|
|
if (!input.actor.runId || resolution.kind !== "low_trust_review") return resolution;
|
|
return withHumanDirectedWork(db, resolution, {
|
|
companyId: input.companyId, agentId: input.actorAgent.id, runId: input.actor.runId,
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
// Only an explicit root scope includes descendants. A parent in a permitted
|
|
// project (or an exact issue-id scope) cannot authorize a child elsewhere.
|
|
if (!resource.parentIssueId || !boundary.rootIssueId) return false;
|
|
const parent = await loadIssue(resource.parentIssueId);
|
|
if (!parent || parent.companyId !== boundary.companyId) return false;
|
|
// Root ancestry permits decomposition, not selection of an unrelated
|
|
// project. Projectless children remain possible for projectless roots.
|
|
if (candidate.projectId && !await projectWithinLowTrustBoundary(boundary, candidate.projectId)) return false;
|
|
return parent.id === boundary.rootIssueId ||
|
|
issueIdIsDescendantOf(parent.id, boundary.rootIssueId, boundary.companyId);
|
|
}
|
|
|
|
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;
|
|
actor: AuthorizationActor;
|
|
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 === "agent_instructions:update") {
|
|
if (input.resource.type === "agent" && input.resource.agentId === input.actorAgentId &&
|
|
await hasOwnerChatInstructionAuthority(db, {
|
|
companyId: boundary.companyId,
|
|
agentId: input.actorAgentId,
|
|
runId: input.actor.runId,
|
|
userId: input.actor.onBehalfOfUserId,
|
|
})) {
|
|
// Continue through self-instruction permissions, explicit protected
|
|
// change restrictions, and the responsible user's current edit access.
|
|
return null;
|
|
}
|
|
return lowTrustDeny(
|
|
"This low-trust run cannot change persistent agent instructions, including AGENTS.md. Self-edits require a direct message in the authorized user's chat with this agent. Outside-triggered work and subtasks do not inherit that authority; ask the authorized user to request the edit in their chat or apply it directly.",
|
|
);
|
|
}
|
|
|
|
if (
|
|
input.action === "company_scope:read" ||
|
|
// Agent creation is a company-wide privileged action. The default-on
|
|
// canCreateAgents flag must never reach the legacy creator allow when
|
|
// the effective execution context (agent, project, issue, or run
|
|
// policy) resolves to low trust.
|
|
input.action === "agents:create" ||
|
|
input.action === "decision_queue:manage" ||
|
|
input.action === "decision_queue:read" ||
|
|
input.action === "decision_triage:manage" ||
|
|
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" ||
|
|
input.action === "secrets:propose"
|
|
) {
|
|
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.resolution.humanDirectedIssueId && input.resource.companyId === boundary.companyId &&
|
|
input.resource.issueId === input.resolution.humanDirectedIssueId) {
|
|
return lowTrustAllow("Allowed on the exact task directed by an authenticated human.");
|
|
}
|
|
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 === "decision_queue:manage" ||
|
|
input.action === "decision_queue:read" ||
|
|
input.action === "decision_triage:manage" ||
|
|
input.action === "agent:read" ||
|
|
input.action === "agent:wake" ||
|
|
input.action === "project:read" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read" ||
|
|
input.action === "secrets:propose"
|
|
) {
|
|
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 === "decision_queue:manage" ||
|
|
input.action === "decision_queue:read" ||
|
|
input.action === "decision_triage:manage" ||
|
|
input.action === "agent:read" ||
|
|
input.action === "agent:wake" ||
|
|
input.action === "project:read" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read" ||
|
|
input.action === "secrets:propose" ||
|
|
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 === "blocked") ??
|
|
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);
|
|
|
|
/**
|
|
* Shared default-open decision for issue write-influence channels.
|
|
*
|
|
* Keep visibility structurally upstream of every standard-trust write so
|
|
* future visibility scoping can be implemented in issue:read without
|
|
* recreating per-action scope checks. The responsible-user ceiling remains
|
|
* in decide(), after this base decision.
|
|
*/
|
|
async function decideVisibleIssueWrite(): Promise<AuthorizationDecision> {
|
|
let visibilityResource: AuthorizationResource = input.resource;
|
|
|
|
// New-child assignment decisions identify the issue being influenced by
|
|
// parentIssueId. Resolve that parent into the same resource shape used by
|
|
// issue:read so child-create and assign share the visibility hook.
|
|
if (
|
|
input.resource.type === "issue" &&
|
|
!input.resource.issueId &&
|
|
input.resource.parentIssueId
|
|
) {
|
|
const parent = await loadIssue(input.resource.parentIssueId);
|
|
if (!parent || parent.companyId !== companyId) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_company_boundary",
|
|
explanation: "The issue write target is not visible in this company.",
|
|
});
|
|
}
|
|
visibilityResource = {
|
|
type: "issue",
|
|
companyId: parent.companyId,
|
|
issueId: parent.id,
|
|
projectId: parent.projectId,
|
|
parentIssueId: parent.parentId,
|
|
assigneeAgentId: parent.assigneeAgentId,
|
|
assigneeUserId: parent.assigneeUserId,
|
|
originKind: parent.originKind,
|
|
originId: parent.originId,
|
|
status: parent.status,
|
|
};
|
|
}
|
|
|
|
const visibilityDecision = visibilityResource.type === "issue" && visibilityResource.issueId
|
|
? await decideBase({
|
|
actor: input.actor,
|
|
action: "issue:read",
|
|
resource: visibilityResource,
|
|
scope: input.scope,
|
|
})
|
|
: await decideBase({
|
|
actor: input.actor,
|
|
action: "company_scope:read",
|
|
resource: { type: "company", companyId },
|
|
scope: input.scope,
|
|
});
|
|
|
|
if (!visibilityDecision.allowed) {
|
|
return {
|
|
...visibilityDecision,
|
|
action: input.action,
|
|
explanation: `Issue write denied because the target is not visible: ${visibilityDecision.explanation}`,
|
|
};
|
|
}
|
|
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_visible_issue_write",
|
|
explanation: "Allowed by the shared default-open visible-issue write rule.",
|
|
});
|
|
}
|
|
|
|
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.",
|
|
});
|
|
}
|
|
// MCP grants always use their explicitly consented company membership,
|
|
// even when the consenting person is an instance administrator.
|
|
// 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.source !== "mcp_oauth" &&
|
|
(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 === "decision_queue: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" ||
|
|
input.action === "decision_queue:manage" ||
|
|
input.action === "decision_triage:manage"
|
|
) &&
|
|
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" || input.action === "agent_instructions: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 === "issue:comment" || input.action === "issue:mutate") {
|
|
if (
|
|
input.resource.type !== "issue" ||
|
|
!input.resource.issueId ||
|
|
typeof input.resource.status !== "string" ||
|
|
input.resource.assigneeAgentId === undefined ||
|
|
input.resource.assigneeUserId === undefined
|
|
) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_unsupported_action",
|
|
explanation: `No board permission mapping exists for ${input.action}.`,
|
|
});
|
|
}
|
|
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
|
|
if (membership && membership.membershipRole !== "viewer") {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_simple_company_member",
|
|
explanation: "Allowed by standard same-company board membership issue mutation.",
|
|
});
|
|
}
|
|
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}.`,
|
|
});
|
|
}
|
|
if (
|
|
input.action === "agent:read" ||
|
|
input.action === "agent:wake" ||
|
|
input.action === "company_scope:read" ||
|
|
input.action === "decision_queue:manage" ||
|
|
input.action === "decision_queue:read" ||
|
|
input.action === "decision_triage:manage" ||
|
|
input.action === "issue:read" ||
|
|
input.action === "project:read" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read" ||
|
|
input.action === "secrets:propose"
|
|
) {
|
|
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 === "agent:wake" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read" ||
|
|
input.action === "decision_queue:manage" ||
|
|
input.action === "decision_triage:manage";
|
|
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.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,
|
|
actor: input.actor,
|
|
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 === "decision_queue:read" ||
|
|
input.action === "issue:comment" ||
|
|
input.action === "issue:read" ||
|
|
input.action === "project:read" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read" ||
|
|
input.action === "secrets:propose"
|
|
) {
|
|
return lowTrustDecision;
|
|
}
|
|
}
|
|
|
|
const visibleIssueWriteDecision =
|
|
trustResolution.kind === "standard" &&
|
|
(input.action === "issue:comment" || input.action === "issue:mutate")
|
|
? await decideVisibleIssueWrite()
|
|
: null;
|
|
if (visibleIssueWriteDecision && !visibleIssueWriteDecision.allowed) {
|
|
return visibleIssueWriteDecision;
|
|
}
|
|
|
|
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}.`,
|
|
});
|
|
}
|
|
|
|
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 (targetUserId !== responsibleUserId) {
|
|
// A scoped grant remains an administrative override, including over a
|
|
// disabled user policy. Otherwise, a materialized target-user policy is
|
|
// explicit consent for agents selected in the profile control. The
|
|
// implicit default-open policy remains responsible-user-only so an
|
|
// absent row never becomes a company-wide cross-user grant.
|
|
const grant = await findGrant(companyId, "agent", actorAgentId, "inbox:manage");
|
|
if (grant && grant.scope?.responsibleUserOnly !== true &&
|
|
(await scopeAllows(db, companyId, grant.scope, { userId: targetUserId }))) {
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
|
|
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.`,
|
|
});
|
|
}
|
|
if (policy?.mode === "open" || policy?.mode === "allowlist") {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_user_inbox_policy",
|
|
inboxPolicyMode: policy.mode,
|
|
explanation: policy.mode === "allowlist"
|
|
? "Allowed by the target user's inbox agent allowlist."
|
|
: "Allowed by the target user's saved open inbox policy.",
|
|
});
|
|
}
|
|
|
|
if (grant) {
|
|
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 deny({
|
|
action: input.action,
|
|
reason: "deny_missing_grant",
|
|
explanation: "Missing permission: inbox:manage.",
|
|
});
|
|
}
|
|
|
|
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 === "decision_queue:read" ||
|
|
input.action === "issue:read" ||
|
|
input.action === "project:read" ||
|
|
input.action === "runtime:manage" ||
|
|
input.action === "secrets:read" ||
|
|
input.action === "secrets:propose"
|
|
) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_company_agent",
|
|
explanation: "Allowed by standard same-company agent visibility.",
|
|
});
|
|
}
|
|
|
|
if (input.action === "decision_queue:manage" || input.action === "decision_triage:manage") {
|
|
if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
|
|
return deny({
|
|
action: input.action,
|
|
reason: "deny_missing_membership",
|
|
explanation: "Actor agent is not active in the target company.",
|
|
});
|
|
}
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_company_agent",
|
|
explanation: "Allowed for an active standard-scope company agent.",
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
if (trustResolution.kind === "standard") return decideVisibleIssueWrite();
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_simple_company_member",
|
|
explanation: "Allowed by the existing bounded task assignment rule.",
|
|
});
|
|
}
|
|
|
|
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 (visibleIssueWriteDecision) return visibleIssueWriteDecision;
|
|
}
|
|
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_instructions:update") {
|
|
if (!isSimpleAssignableAgentStatus(actorAgent.status) || !input.actor.onBehalfOfUserId) {
|
|
return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Instruction edits require an active agent and a responsible user." });
|
|
}
|
|
// Explicit configure/suggest restrictions still govern content changes.
|
|
// Only self edits may fall back to the responsible user's target access.
|
|
const restricted = await decideWithProtectedChangeGrants("agent", actorAgentId, {
|
|
direct: "agents:configure", suggest: "agents:suggest-changes",
|
|
});
|
|
if (restricted.reason !== "deny_no_grant" || input.resource.type !== "agent" || input.resource.agentId !== actorAgentId) return restricted;
|
|
return allow({ action: input.action, reason: "allow_self", explanation: "Own instruction content edit, subject to the responsible user's target edit access." });
|
|
}
|
|
|
|
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" && canCreateAgentsLegacy(actorAgent)) {
|
|
return allow({
|
|
action: input.action,
|
|
reason: "allow_legacy_agent_creator",
|
|
explanation: "Allowed by legacy agent creator authority.",
|
|
});
|
|
}
|
|
|
|
// Active-checkout management deliberately does not ride on
|
|
// canCreateAgents: that flag is default-on for standard-trust agents, and
|
|
// coupling would let any peer write over another agent's checked-out
|
|
// issue. CEOs, explicit grants, and the manager chain remain the paths.
|
|
if (input.action === "tasks:manage_active_checkouts" && actorAgent.role === "ceo") {
|
|
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 input.action !== "agent_instructions:update" && 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);
|
|
const intersectedDecision = await applyResponsibleUserIntersection(input, agentDecision);
|
|
if (
|
|
input.action === "project:read"
|
|
&& input.resource.type === "project"
|
|
&& input.resource.projectId
|
|
&& intersectedDecision.allowed
|
|
&& intersectedDecision.reason !== "allow_instance_admin"
|
|
&& intersectedDecision.reason !== "allow_local_board"
|
|
&& issuePrivacyMode() !== "off"
|
|
) {
|
|
const project = await db
|
|
.select({ id: projects.id, companyId: projects.companyId, visibility: projects.visibility })
|
|
.from(projects)
|
|
.where(and(eq(projects.id, input.resource.projectId), eq(projects.companyId, input.resource.companyId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (project && !(await canActorReadProjectPrivacy(db, input.actor, project))) {
|
|
const denied = deny({
|
|
action: input.action,
|
|
reason: "deny_project_private",
|
|
explanation: "Project is private and the actor is not an access member.",
|
|
});
|
|
logger.warn({
|
|
authzMode: issuePrivacyMode(),
|
|
action: input.action,
|
|
companyId: project.companyId,
|
|
projectId: project.id,
|
|
actorType: input.actor.type,
|
|
actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null,
|
|
actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null,
|
|
reason: denied.reason,
|
|
}, "project privacy would deny read");
|
|
return issuePrivacyMode() === "shadow" ? intersectedDecision : denied;
|
|
}
|
|
}
|
|
if (
|
|
input.action !== "issue:read"
|
|
|| input.resource.type !== "issue"
|
|
|| !input.resource.issueId
|
|
|| !intersectedDecision.allowed
|
|
|| intersectedDecision.reason === "allow_instance_admin"
|
|
|| intersectedDecision.reason === "allow_local_board"
|
|
|| issuePrivacyMode() === "off"
|
|
) return intersectedDecision;
|
|
|
|
const issue = await db
|
|
.select({
|
|
id: issues.id,
|
|
companyId: issues.companyId,
|
|
visibility: issues.visibility,
|
|
privacyRootIssueId: issues.privacyRootIssueId,
|
|
responsibleUserId: issues.responsibleUserId,
|
|
createdByUserId: issues.createdByUserId,
|
|
assigneeUserId: issues.assigneeUserId,
|
|
assigneeAgentId: issues.assigneeAgentId,
|
|
projectId: issues.projectId,
|
|
})
|
|
.from(issues)
|
|
.where(and(eq(issues.id, input.resource.issueId), eq(issues.companyId, input.resource.companyId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!issue || await canActorReadIssuePrivacy(db, input.actor, issue)) return intersectedDecision;
|
|
|
|
const denied = deny({
|
|
action: input.action,
|
|
reason: "deny_issue_private",
|
|
explanation: "Issue is private and the actor is not an implicit principal or active grantee.",
|
|
});
|
|
logger.warn({
|
|
authzMode: issuePrivacyMode(),
|
|
action: input.action,
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
privacyRootIssueId: issue.privacyRootIssueId,
|
|
actorType: input.actor.type,
|
|
actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null,
|
|
actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null,
|
|
reason: denied.reason,
|
|
}, "issue privacy would deny read");
|
|
return issuePrivacyMode() === "shadow" ? intersectedDecision : denied;
|
|
}
|
|
|
|
// A candidate filter only: project policies and responsible-user grants are
|
|
// still evaluated by decide(). Project policies can contribute an additional
|
|
// root/project scope, so the query must also retain projects with such policy.
|
|
async function projectDiscoveryCandidateIds(actor: AuthorizationActor, companyId: string): Promise<string[] | null> {
|
|
if (actor.type !== "agent" || !actor.agentId || actor.keyScope) return null;
|
|
const agent = await loadAgent(actor.agentId);
|
|
if (!agent || agent.companyId !== companyId) return [];
|
|
const run = await loadRunPolicy(actor.runId, companyId, agent.id);
|
|
const resolution = resolveCoreTrustPreset({ companyId, agent, run });
|
|
// A project can supply a missing boundary; never prefilter that case.
|
|
if (resolution.kind !== "low_trust_review") return null;
|
|
const ids = new Set(resolution.boundary.projectIds ?? []);
|
|
if (resolution.boundary.rootIssueId) {
|
|
const root = await loadIssue(resolution.boundary.rootIssueId);
|
|
if (root?.companyId === companyId && root.projectId) ids.add(root.projectId);
|
|
}
|
|
return [...ids];
|
|
}
|
|
|
|
return {
|
|
decide,
|
|
projectDiscoveryCandidateIds,
|
|
decidePrincipalGrant,
|
|
};
|
|
}
|
|
|
|
/** A workspace can contain several tasks. Reading files/operations requires
|
|
* access to every task bound to it, not just the task that originally made it. */
|
|
export async function executionWorkspaceReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
|
const issueCondition = await issueReadSqlCondition(db, actor);
|
|
const projectCondition = await projectReadSqlCondition(db, actor);
|
|
return sql<boolean>`(
|
|
(${executionWorkspaces.projectId} is null or exists (
|
|
select 1 from ${projects} where ${projects.id} = ${executionWorkspaces.projectId}
|
|
and ${projects.companyId} = ${executionWorkspaces.companyId} and ${projectCondition}))
|
|
and not exists (select 1 from jsonb_object_keys(coalesce(${executionWorkspaces.metadata}->'_issuePrivacySources', '{}'::jsonb)) source(id)
|
|
where not exists (select 1 from ${issues} where ${issues.id}::text = source.id
|
|
and ${issues.companyId} = ${executionWorkspaces.companyId} and ${issueCondition}))
|
|
and not exists (select 1 from ${issues}
|
|
where ${issues.companyId} = ${executionWorkspaces.companyId}
|
|
and (${issues.executionWorkspaceId} = ${executionWorkspaces.id} or ${issues.id} = ${executionWorkspaces.sourceIssueId})
|
|
and not ${issueCondition})
|
|
)`;
|
|
}
|
|
|
|
/** An approval may contain task-derived payload; every linked task must be readable. */
|
|
export async function approvalReadSqlCondition(db: Db | DbTransaction, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
|
const readable = await issueReadSqlCondition(db, actor);
|
|
return sql<boolean>`not exists (
|
|
select 1 from (values (${approvals.payload}->>'issueId'), (${approvals.payload}->>'sourceIssueId')) claimed(id)
|
|
where claimed.id is not null and not exists (
|
|
select 1 from ${issues} where ${issues.id}::text = claimed.id and ${issues.companyId} = ${approvals.companyId})
|
|
) and not exists (
|
|
select 1 from ${issues} where ${issues.companyId} = ${approvals.companyId}
|
|
and (${issues.id}::text = ${approvals.payload}->>'issueId'
|
|
or ${issues.id}::text = ${approvals.payload}->>'sourceIssueId'
|
|
or exists (select 1 from ${issueApprovals} ia where ia.approval_id = ${approvals.id}
|
|
and ia.company_id = ${approvals.companyId} and ia.issue_id = ${issues.id}))
|
|
and not (${readable})
|
|
)`;
|
|
}
|
|
|
|
export async function canActorReadApproval(db: Db | DbTransaction, actor: AuthorizationActor, approvalId: string) {
|
|
return db.select({ id: approvals.id }).from(approvals)
|
|
.where(and(eq(approvals.id, approvalId), await approvalReadSqlCondition(db, actor)))
|
|
.limit(1).then(rows => rows.length > 0);
|
|
}
|
|
|
|
export async function canActorReadExecutionWorkspace(db: Db, actor: AuthorizationActor, id: string) {
|
|
return db.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
|
|
.where(and(eq(executionWorkspaces.id, id), await executionWorkspaceReadSqlCondition(db, actor)))
|
|
.limit(1).then(rows => rows.length > 0);
|
|
}
|
|
|
|
/** Recheck the external audience immediately before publishing task-derived content. */
|
|
export async function canPublishIssueToChatAudience(
|
|
tx: Db | DbTransaction,
|
|
publication: { issueId: string; companyId: string; conversationId: string; endpointId: string },
|
|
): Promise<boolean> {
|
|
const [privacyIssue] = await tx.select().from(issues)
|
|
.where(and(eq(issues.id, publication.issueId), eq(issues.companyId, publication.companyId)));
|
|
if (!privacyIssue) return false;
|
|
if (!(await canActorReadIssuePrivacy(tx, { type: "none" }, privacyIssue))) {
|
|
const [conversation] = await tx.select().from(chatConversations).where(and(
|
|
eq(chatConversations.id, publication.conversationId), eq(chatConversations.companyId, publication.companyId),
|
|
eq(chatConversations.endpointId, publication.endpointId)));
|
|
// Shared channels have no bounded, authenticated recipient set. Private
|
|
// output may leave only through a DM whose known recipients still qualify.
|
|
if (!conversation?.isDirectMessage) return false;
|
|
const recipients = await tx.selectDistinct({ principalId: chatDeliveries.principalId }).from(chatDeliveries)
|
|
.where(and(eq(chatDeliveries.conversationId, conversation.id), eq(chatDeliveries.companyId, publication.companyId),
|
|
inArray(chatDeliveries.eventKind, ["message", "mention"])));
|
|
if (!recipients.length || recipients.some(recipient => !recipient.principalId)) return false;
|
|
for (const recipient of recipients) {
|
|
const [link] = await tx.select().from(chatIdentityLinks).where(and(
|
|
eq(chatIdentityLinks.companyId, publication.companyId), eq(chatIdentityLinks.endpointId, publication.endpointId),
|
|
eq(chatIdentityLinks.principalId, recipient.principalId!), eq(chatIdentityLinks.status, "linked"), isNull(chatIdentityLinks.revokedAt)));
|
|
if (!link?.paperclipUserId) return false;
|
|
const allowed = await authorizationService(tx).decide({ actor: { type: "board", userId: link.paperclipUserId, source: "session", ignoreInstanceAdmin: true },
|
|
action: "issue:read", resource: { type: "issue", companyId: publication.companyId, issueId: publication.issueId } });
|
|
if (!allowed.allowed) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|