mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Add company-scoped Muse native control, OAuth, wake receiver and recovery. Recheck current authority after waits and before effects or receipt replay. Preserve task-read scope, assignment expiry, independent holds, nonblocking reassignment and truthful cleanup provenance. Retain source trust on progress comments and verify current Muse V8 authority through real signed-JWT HTTP requests. Preserve the upstream trimmed ordinary authentication lookup. Refs #16448. Preserve captured canonical queue recovery and concurrent environment preparation while retaining the scoped Muse V8 exclusion. Normal migration 0354 and existing Muse SQL remain unchanged. Fresh current-head Greptile review is 5/5 with the project-write finding addressed. Required CI and independent technical review gate the merge; maintainer override is reserved for formal approval rules. Recheck current Muse project-write authority after all receipt/task/run/admission waits, before creation or replay. Real PostgreSQL regressions reproduce four late effects/replays on the old route; the repaired three-file treatment passes 354 cases with no skips, and the scoped server typecheck passes. Ordinary/Dot behavior is preserved. Co-Authored-By: Paperclip <noreply@paperclip.ing>
2985 lines
116 KiB
TypeScript
2985 lines
116 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 { canonicalExecutionUuid } from "./execution-identity.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;
|
|
/** Pinned, server-derived external authorizer; never an agent-selected principal. */
|
|
authorizingUserId?: 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"
|
|
| "project:write_workspace"
|
|
| "runtime:manage"
|
|
| "runtime:confirm_owner_stop"
|
|
| "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 === "project:write_workspace") return "tasks:assign";
|
|
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 === "runtime:confirm_owner_stop" ||
|
|
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);
|
|
const userIds = actor.type === "agent"
|
|
? [...new Set([actor.onBehalfOfUserId, actor.authorizingUserId].filter((id): id is string => !!id))] : [];
|
|
return userIds.reduce((condition, userId) => sql<boolean>`(${condition}
|
|
and ${projectPrivacyPrincipalCondition({ type: "board", userId, ignoreInstanceAdmin: true })}
|
|
and exists (select 1 from ${companyMemberships} m where m.company_id = ${projects.companyId}
|
|
and m.principal_type = 'user' and m.principal_id = ${userId} and m.status = 'active'))`, own);
|
|
}
|
|
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 own = issuePrivacyPrincipalCondition(actor);
|
|
const userIds = actor.type === "agent"
|
|
? [...new Set([actor.onBehalfOfUserId, actor.authorizingUserId].filter((id): id is string => !!id))] : [];
|
|
// Shared agent grants cannot amplify either the task owner or external authorizer.
|
|
return userIds.reduce((condition, userId) => sql<boolean>`(${condition}
|
|
and ${issuePrivacyPrincipalCondition({ type: "board", userId, ignoreInstanceAdmin: true })}
|
|
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'))`, own);
|
|
}
|
|
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 (actor.authorizingUserId) return issuePrivacyCondition(actor);
|
|
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 (actor.authorizingUserId) return projectPrivacyCondition(actor);
|
|
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];
|
|
|
|
type ReadAuthorizationSnapshot = {
|
|
issues: Map<string, IssueAuthorizationRow & IssuePrivacyRow & { readable: boolean }>;
|
|
projects: Map<string, ProjectAuthorizationRow & ProjectPrivacyRow & { readable: boolean }>;
|
|
runs: Map<string, { companyId: string; agentId: string; contextSnapshot: Record<string, unknown> } | null>;
|
|
memo: Map<string, Promise<unknown>>;
|
|
};
|
|
|
|
type ReadAuthorizationResource = Extract<AuthorizationResource, { type: "issue" | "project" | "company" }>;
|
|
|
|
export function authorizationService(db: Db | DbTransaction) {
|
|
return createAuthorizationService(db);
|
|
}
|
|
|
|
function createAuthorizationService(db: Db | DbTransaction, readSnapshot?: ReadAuthorizationSnapshot) {
|
|
function batchRead<T>(key: string, read: () => Promise<T>): Promise<T> {
|
|
if (!readSnapshot) return read();
|
|
let result = readSnapshot.memo.get(key);
|
|
if (!result) { result = read(); readSnapshot.memo.set(key, result); }
|
|
return result as Promise<T>;
|
|
}
|
|
|
|
async function decideReads(actor: AuthorizationActor, resources: ReadAuthorizationResource[]): Promise<AuthorizationDecision[]> {
|
|
return (await decideReadsForActors([actor], resources))[0];
|
|
}
|
|
|
|
async function decideReadsForActors(actors: AuthorizationActor[], resources: ReadAuthorizationResource[]): Promise<AuthorizationDecision[][]> {
|
|
if (!actors.length) return [];
|
|
const memo = new Map<string, Promise<unknown>>();
|
|
const runs: ReadAuthorizationSnapshot["runs"] = new Map();
|
|
const snapshots: ReadAuthorizationSnapshot[] = actors.map(() => ({ issues: new Map(), projects: new Map(), runs, memo }));
|
|
const batchActors: ResponsibleUserActorWithMemo[] = actors.map(actor => ({ ...actor, __responsibleUserSnapshotMemo: undefined }));
|
|
// Only canonical IDs take this fast path. Other inputs keep the ordinary
|
|
// lookup behavior, including PostgreSQL's handling of invalid UUIDs.
|
|
const runIds = resources.length ? [...new Set(batchActors.flatMap(actor => actor.type === "agent" && actor.runId
|
|
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(actor.runId) ? [actor.runId] : []))] : [];
|
|
for (let offset = 0; offset < runIds.length; offset += 500) {
|
|
const ids = runIds.slice(offset, offset + 500);
|
|
for (const id of ids) runs.set(id, null);
|
|
const rows = await db.select({ id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
|
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`,
|
|
}).from(heartbeatRuns).where(inArray(heartbeatRuns.id, ids));
|
|
for (const row of rows) runs.set(row.id, row);
|
|
}
|
|
const privacyKey = (actor: AuthorizationActor) => JSON.stringify([actor.type, actor.agentId, actor.userId, actor.onBehalfOfUserId]);
|
|
const privacyActors = [...new Map(batchActors.map(actor => [privacyKey(actor), actor])).values()];
|
|
const privacyIndexes = batchActors.map(actor => privacyActors.findIndex(peer => privacyKey(peer) === privacyKey(actor)));
|
|
const issueIds = [...new Set(resources.flatMap(resource => resource.type === "issue" && resource.issueId ? [resource.issueId] : []))];
|
|
const projectIds = new Set(resources.flatMap(resource => resource.type === "project" && resource.projectId ? [resource.projectId]
|
|
: resource.type === "issue" && resource.projectId ? [resource.projectId] : []));
|
|
for (let offset = 0; offset < issueIds.length; offset += 500) {
|
|
const rows = await 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,
|
|
visibility: issues.visibility, privacyRootIssueId: issues.privacyRootIssueId,
|
|
responsibleUserId: issues.responsibleUserId, createdByUserId: issues.createdByUserId,
|
|
readable: sql<boolean[]>`ARRAY[${sql.join(privacyActors.map(actor => issuePrivacyCondition(actor)), sql`, `)}]`,
|
|
}).from(issues).where(inArray(issues.id, issueIds.slice(offset, offset + 500)));
|
|
for (const row of rows) {
|
|
for (let index = 0; index < snapshots.length; index++) snapshots[index].issues.set(row.id, { ...row, readable: row.readable[privacyIndexes[index]] });
|
|
if (row.projectId) projectIds.add(row.projectId);
|
|
}
|
|
}
|
|
const ids = [...projectIds];
|
|
for (let offset = 0; offset < ids.length; offset += 500) {
|
|
const rows = await db.select({ id: projects.id, companyId: projects.companyId,
|
|
executionWorkspacePolicy: projects.executionWorkspacePolicy, visibility: projects.visibility,
|
|
readable: sql<boolean[]>`ARRAY[${sql.join(privacyActors.map(actor => projectPrivacyCondition(actor)), sql`, `)}]`,
|
|
}).from(projects).where(inArray(projects.id, ids.slice(offset, offset + 500)));
|
|
for (const row of rows) for (let index = 0; index < snapshots.length; index++) {
|
|
snapshots[index].projects.set(row.id, { ...row, readable: row.readable[privacyIndexes[index]] });
|
|
}
|
|
}
|
|
const results: AuthorizationDecision[][] = [];
|
|
for (let index = 0; index < batchActors.length; index++) {
|
|
const batch = createAuthorizationService(db, snapshots[index]);
|
|
const decisions: AuthorizationDecision[] = [];
|
|
for (const resource of resources) decisions.push(await batch.decide({ actor: batchActors[index], resource,
|
|
action: resource.type === "issue" ? "issue:read" : resource.type === "project" ? "project:read" : "company_scope:read" }));
|
|
results.push(decisions);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
|
if (!userId) return false;
|
|
return batchRead(`admin:${userId}`, async () => {
|
|
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 batchRead(`membership:${companyId}:${principalType}:${principalId}`, async () => {
|
|
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 = batchRead(`responsible:${input.companyId}:${input.userId}`,
|
|
() => 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 batchRead(`agent:${agentId}`, async () => {
|
|
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> {
|
|
if (readSnapshot?.projects.has(projectId)) return readSnapshot.projects.get(projectId)!;
|
|
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> {
|
|
if (readSnapshot?.issues.has(issueId)) return readSnapshot.issues.get(issueId)!;
|
|
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) {
|
|
return batchRead(`run:${companyId}:${agentId}:${runId}`, async () => {
|
|
if (!runId) return null;
|
|
const row = readSnapshot?.runs.has(runId) ? readSnapshot.runs.get(runId) : await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object('executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`,
|
|
})
|
|
.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: sql<Record<string, unknown>>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId', 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId')`,
|
|
})
|
|
.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" || input.action === "project:write_workspace") {
|
|
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 === "project:write_workspace" ||
|
|
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 === "project:write_workspace" ||
|
|
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);
|
|
if (input.action === "project:write_workspace" && (input.resource.type !== "issue" || !input.resource.projectId)) {
|
|
return deny({ action: input.action, reason: "deny_unsupported_action", explanation: "Workspace writes require a source project and task context." });
|
|
}
|
|
const taskAssignmentAction = input.action === "tasks:assign" || input.action === "project:write_workspace";
|
|
const requestedAssignmentPolicy = () => input.action === "project:write_workspace" && input.resource.type === "issue"
|
|
? loadProjectAuthorizationPolicy(companyId, input.resource.projectId!).then(policy => evaluateAuthorizationPolicyForAssignment(policy, "Target project"))
|
|
: assignmentPolicyEffect(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.action === "runtime:confirm_owner_stop" && input.actor.type !== "board") {
|
|
return deny({ action: input.action, reason: "deny_unsupported_action",
|
|
explanation: "Only a deployment administrator can confirm a workspace controller has stopped." });
|
|
}
|
|
|
|
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.",
|
|
});
|
|
}
|
|
if (input.action === "runtime:confirm_owner_stop") {
|
|
const membership = await getActiveMembership(companyId, "user", input.actor.userId ?? "");
|
|
if (membership?.membershipRole === "owner" || membership?.membershipRole === "admin") {
|
|
return allow({ action: input.action, reason: "allow_role_default",
|
|
explanation: "Allowed by the active company owner or administrator role." });
|
|
}
|
|
return deny({ action: input.action, reason: "deny_missing_grant",
|
|
explanation: "Only a company owner, company administrator, or instance administrator can confirm a workspace controller has stopped." });
|
|
}
|
|
// 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 (taskAssignmentAction) {
|
|
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 requestedAssignmentPolicy();
|
|
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 (taskAssignmentAction) {
|
|
const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId);
|
|
if (grantDecision.allowed) return grantDecision;
|
|
const policyEffect = taskAssignmentPolicyEffect ?? await requestedAssignmentPolicy();
|
|
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 (taskAssignmentAction) {
|
|
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 requestedAssignmentPolicy();
|
|
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 applyAuthorizingUserIntersection(
|
|
input: {
|
|
actor: AuthorizationActor;
|
|
action: AuthorizationAction;
|
|
resource: AuthorizationResource;
|
|
scope?: Record<string, unknown> | null;
|
|
},
|
|
agentDecision: AuthorizationDecision,
|
|
): Promise<AuthorizationDecision> {
|
|
const authorizingUserId = input.actor.authorizingUserId?.trim();
|
|
if (input.actor.type !== "agent" || !authorizingUserId || !agentDecision.allowed) return agentDecision;
|
|
|
|
const companyId = companyIdForResource(input.resource);
|
|
// External authority always uses current membership, without the request's
|
|
// responsible-user memo or rollout exceptions for agent-owned grants.
|
|
const snapshot = await loadResponsibleUserSnapshot(companyId, authorizingUserId);
|
|
const userDecision = snapshot.userExists && snapshot.activeMembership
|
|
? await decideBase({
|
|
...input,
|
|
actor: { type: "board", userId: authorizingUserId, ignoreInstanceAdmin: true, source: "session" },
|
|
})
|
|
: deny({
|
|
action: input.action,
|
|
reason: "deny_missing_membership",
|
|
explanation: "The authorizing user is not an active company member.",
|
|
});
|
|
if (userDecision.allowed) return agentDecision;
|
|
|
|
return deny({
|
|
action: input.action,
|
|
reason: userDecision.reason,
|
|
explanation: `Authorizing user is not authorized for ${input.action}: ${userDecision.explanation}`,
|
|
grant: userDecision.grant,
|
|
});
|
|
}
|
|
|
|
async function decide(input: {
|
|
actor: AuthorizationActor;
|
|
action: AuthorizationAction;
|
|
resource: AuthorizationResource;
|
|
scope?: Record<string, unknown> | null;
|
|
}): Promise<AuthorizationDecision> {
|
|
const agentDecision = await decideBase(input);
|
|
const responsibleDecision = await applyResponsibleUserIntersection(input, agentDecision);
|
|
const intersectedDecision = await applyAuthorizingUserIntersection(input, responsibleDecision);
|
|
if (
|
|
input.action === "project:read"
|
|
&& input.resource.type === "project"
|
|
&& input.resource.projectId
|
|
&& intersectedDecision.allowed
|
|
&& intersectedDecision.reason !== "allow_instance_admin"
|
|
&& intersectedDecision.reason !== "allow_local_board"
|
|
&& (input.actor.authorizingUserId || issuePrivacyMode() !== "off")
|
|
) {
|
|
const retainedProject = readSnapshot?.projects.get(input.resource.projectId);
|
|
const project = retainedProject?.companyId === input.resource.companyId ? retainedProject : 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 && !(retainedProject?.companyId === input.resource.companyId
|
|
? retainedProject.readable : 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 !input.actor.authorizingUserId && 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"
|
|
|| (!input.actor.authorizingUserId && issuePrivacyMode() === "off")
|
|
) return intersectedDecision;
|
|
|
|
const retainedIssue = readSnapshot?.issues.get(input.resource.issueId);
|
|
const issue = retainedIssue?.companyId === input.resource.companyId ? retainedIssue : 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 || (retainedIssue?.companyId === input.resource.companyId
|
|
? retainedIssue.readable : 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 !input.actor.authorizingUserId && 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,
|
|
decideReads,
|
|
decideReadsForActors,
|
|
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} = ${canonicalExecutionUuid(sql`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;
|
|
}
|