mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
[codex] Add agent permissions and controls plan (#6386)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies by keeping task ownership, approvals, and operator control inside one control plane. > - Agent permissions and plugin-hosted company settings sit on the boundary between autonomy and governance. > - V1 needs scoped task assignment rules, plugin extension points, and clearer company access surfaces without weakening company boundaries. > - The branch builds the core authorization service, plugin SDK/host APIs, and UI simplifications needed to support those controls. > - Paperclip EE plugin surfaces were intentionally moved out of this core PR per review direction, so this PR now carries only the public core/plugin infrastructure work. > - The latest updates preserve the PAP-9937 branch changes that belong in this PR, remove the `design/` artifacts, and exclude the experimental `plugin-briefs` package. > - Greptile feedback was applied through the authorization/audit paths and the final cleanup commit was re-reviewed at 5/5 with no unresolved Greptile threads. > - The benefit is safer assignment control with extension hooks for richer permission products while preserving simple defaults for normal operators. ## What Changed - Added scoped task-assignment authorization decisions and routed issue/agent assignment mutations through the authorization service. - Added plugin SDK and host APIs for company settings slots, authorization policy/grant management, assignment previews, and bridge invocation scope propagation. - Simplified core company access UI and moved advanced controls behind plugin-provided settings surfaces. - Added retry-now affordances for blocked issue next-step notices. - Added protected-assignment enforcement for persisted agent/project/issue policies, including explicit-grant fallback behavior. - Added incremental principal-access compatibility backfill for active agent memberships and role-default human permission grants. - Added the Markdown code block wrap action fix from the latest branch changes. - Removed `design/` artifacts from the PR and removed `packages/plugins/plugin-briefs` from the final diff. - Addressed Greptile feedback for plugin actor sanitization, legacy membership handling, audit pagination, unknown grant-scope metadata, and startup test mocks. ## Verification - `pnpm exec vitest run server/src/__tests__/access-service.test.ts server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54 tests passed. - `pnpm exec vitest run server/src/__tests__/server-startup-feedback-export.test.ts server/src/__tests__/access-service.test.ts server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62 tests passed. - `pnpm exec vitest run server/src/__tests__/authorization-service.test.ts server/src/__tests__/plugin-access-authorization-host-services.test.ts server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files passed, 28 tests passed. - `pnpm --filter @paperclipai/server typecheck` -> passed. - `git diff --check` -> passed. - `node ./scripts/check-docker-deps-stage.mjs` -> passed. - `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed with no lockfile update. - `pnpm exec vitest run ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed. - `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0. - GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`. - Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0 comments/annotations added, 0 unresolved review threads. - Confirmed the PR diff contains no `design/`, `packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or `.github/workflows` changes. ## Risks - Medium: task assignment authorization paths are behaviorally stricter for protected/private policy data, so existing plugin-authored policies may block assignment until explicit grants or approval flows are configured. - Medium: plugin-host authorization APIs expand the surface area available to trusted plugins and need careful review for company scoping. - Low: startup now performs a principal-access compatibility backfill, but the migration and runtime backfill use conflict-tolerant inserts. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled workflow with shell, git, and GitHub CLI access. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c91a062326
commit
38c185fb8b
102 changed files with 6744 additions and 395 deletions
|
|
@ -9,6 +9,8 @@ import {
|
|||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import { conflict } from "../errors.js";
|
||||
import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js";
|
||||
import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js";
|
||||
|
||||
type MembershipRow = typeof companyMemberships.$inferSelect;
|
||||
type GrantInput = {
|
||||
|
|
@ -24,6 +26,8 @@ type MemberArchiveInput = {
|
|||
};
|
||||
|
||||
export function accessService(db: Db) {
|
||||
const authorization = authorizationService(db);
|
||||
|
||||
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const row = await db
|
||||
|
|
@ -58,21 +62,13 @@ export function accessService(db: Db) {
|
|||
principalId: string,
|
||||
permissionKey: PermissionKey,
|
||||
): Promise<boolean> {
|
||||
const membership = await getMembership(companyId, principalType, principalId);
|
||||
if (!membership || membership.status !== "active") return false;
|
||||
const grant = await db
|
||||
.select({ id: principalPermissionGrants.id })
|
||||
.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);
|
||||
return Boolean(grant);
|
||||
return authorization.decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
permissionKey,
|
||||
action: permissionKey,
|
||||
}).then((decision) => decision.allowed);
|
||||
}
|
||||
|
||||
async function canUser(
|
||||
|
|
@ -80,9 +76,20 @@ export function accessService(db: Db) {
|
|||
userId: string | null | undefined,
|
||||
permissionKey: PermissionKey,
|
||||
): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
if (await isInstanceAdmin(userId)) return true;
|
||||
return hasPermission(companyId, "user", userId, permissionKey);
|
||||
return authorization.decide({
|
||||
actor: { type: "board", userId },
|
||||
action: permissionKey,
|
||||
resource: { type: "company", companyId },
|
||||
}).then((decision) => decision.allowed);
|
||||
}
|
||||
|
||||
async function decide(input: {
|
||||
actor: AuthorizationActor;
|
||||
action: Parameters<typeof authorization.decide>[0]["action"];
|
||||
resource: AuthorizationResource;
|
||||
scope?: Record<string, unknown> | null;
|
||||
}) {
|
||||
return authorization.decide(input);
|
||||
}
|
||||
|
||||
async function listMembers(companyId: string) {
|
||||
|
|
@ -616,10 +623,30 @@ export function accessService(db: Db) {
|
|||
membership.membershipRole,
|
||||
"active",
|
||||
);
|
||||
await ensureHumanRoleDefaultGrants(db, {
|
||||
companyId: targetCompanyId,
|
||||
principalId: membership.principalId,
|
||||
membershipRole: membership.membershipRole,
|
||||
grantedByUserId: null,
|
||||
});
|
||||
}
|
||||
return sourceMemberships;
|
||||
}
|
||||
|
||||
async function ensureRoleDefaultGrants(
|
||||
companyId: string,
|
||||
principalId: string,
|
||||
membershipRole: string | null | undefined,
|
||||
grantedByUserId: string | null,
|
||||
) {
|
||||
return ensureHumanRoleDefaultGrants(db, {
|
||||
companyId,
|
||||
principalId,
|
||||
membershipRole,
|
||||
grantedByUserId,
|
||||
});
|
||||
}
|
||||
|
||||
async function listPrincipalGrants(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
|
|
@ -768,6 +795,7 @@ export function accessService(db: Db) {
|
|||
|
||||
return {
|
||||
isInstanceAdmin,
|
||||
decide,
|
||||
canUser,
|
||||
hasPermission,
|
||||
getMembership,
|
||||
|
|
@ -776,6 +804,7 @@ export function accessService(db: Db) {
|
|||
listMembers,
|
||||
listActiveUserMemberships,
|
||||
copyActiveUserMemberships,
|
||||
ensureRoleDefaultGrants,
|
||||
archiveMember,
|
||||
setMemberPermissions,
|
||||
updateMemberAndPermissions,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ export function normalizeAgentPermissions(
|
|||
}
|
||||
|
||||
const record = permissions as Record<string, unknown>;
|
||||
const preserved = { ...record };
|
||||
return {
|
||||
...preserved,
|
||||
canCreateAgents:
|
||||
typeof record.canCreateAgents === "boolean"
|
||||
? record.canCreateAgents
|
||||
|
|
|
|||
|
|
@ -554,7 +554,7 @@ export function agentService(db: Db) {
|
|||
const updated = await db
|
||||
.update(agents)
|
||||
.set({
|
||||
permissions: normalizeAgentPermissions(permissions, existing.role),
|
||||
permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, id))
|
||||
|
|
|
|||
823
server/src/services/authorization.ts
Normal file
823
server/src/services/authorization.ts
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
companyMemberships,
|
||||
instanceUserRoles,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
|
||||
export type AuthorizationActor =
|
||||
{
|
||||
type: "board" | "agent" | "none";
|
||||
userId?: string | null;
|
||||
companyIds?: string[];
|
||||
memberships?: Array<{ companyId: string; membershipRole?: string | null; status?: string }>;
|
||||
isInstanceAdmin?: boolean;
|
||||
agentId?: string | null;
|
||||
companyId?: string | null;
|
||||
source?:
|
||||
| "local_implicit"
|
||||
| "session"
|
||||
| "board_key"
|
||||
| "agent_key"
|
||||
| "agent_jwt"
|
||||
| "cloud_tenant"
|
||||
| "none";
|
||||
};
|
||||
|
||||
export type AuthorizationAction =
|
||||
| PermissionKey
|
||||
| "agent_config:read"
|
||||
| "agent_config:update"
|
||||
| "issue:mutate";
|
||||
|
||||
export type AuthorizationResource =
|
||||
| { type: "company"; companyId: string }
|
||||
| { type: "agent"; companyId: string; agentId?: string | null }
|
||||
| {
|
||||
type: "issue";
|
||||
companyId: string;
|
||||
issueId?: string | null;
|
||||
projectId?: string | null;
|
||||
parentIssueId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type AuthorizationDecision = {
|
||||
allowed: boolean;
|
||||
action: AuthorizationAction;
|
||||
explanation: string;
|
||||
reason:
|
||||
| "allow_local_board"
|
||||
| "allow_instance_admin"
|
||||
| "allow_explicit_grant"
|
||||
| "allow_legacy_agent_creator"
|
||||
| "allow_self"
|
||||
| "allow_company_agent"
|
||||
| "allow_simple_company_member"
|
||||
| "allow_manager_chain"
|
||||
| "deny_unauthenticated"
|
||||
| "deny_company_boundary"
|
||||
| "deny_missing_membership"
|
||||
| "deny_missing_grant"
|
||||
| "deny_policy_restricted"
|
||||
| "deny_scope"
|
||||
| "deny_unsupported_action";
|
||||
grant?: {
|
||||
principalType: PrincipalType;
|
||||
principalId: string;
|
||||
permissionKey: PermissionKey;
|
||||
scope: Record<string, unknown> | null;
|
||||
};
|
||||
};
|
||||
|
||||
type PrincipalGrantDecision = AuthorizationDecision & {
|
||||
grant?: NonNullable<AuthorizationDecision["grant"]>;
|
||||
};
|
||||
|
||||
function companyIdForResource(resource: AuthorizationResource) {
|
||||
return resource.companyId;
|
||||
}
|
||||
|
||||
function permissionForAction(action: AuthorizationAction): PermissionKey | null {
|
||||
if (action === "agent_config:read" || action === "agent_config:update") return "agents:create";
|
||||
if (action === "issue:mutate") return null;
|
||||
return action;
|
||||
}
|
||||
|
||||
function canCreateAgentsLegacy(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
|
||||
if (agent.role === "ceo") return true;
|
||||
if (!agent.permissions || typeof agent.permissions !== "object") return false;
|
||||
return Boolean(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: "requires_approval"; explanation: string }
|
||||
| { kind: "unknown"; explanation: string };
|
||||
|
||||
type AgentHierarchyRow = { id: string; reportsTo: string | null };
|
||||
|
||||
function evaluateAuthorizationPolicyForAssignment(
|
||||
policy: Record<string, unknown> | null | undefined,
|
||||
label: string,
|
||||
): AssignmentPolicyEffect {
|
||||
if (!policy || objectIsEmpty(policy)) return { kind: "none" };
|
||||
|
||||
const agentVisibility = readPolicyObject(policy, "agentVisibility");
|
||||
const assignmentPolicy = readPolicyObject(policy, "assignmentPolicy");
|
||||
const protectedAgent = readPolicyObject(policy, "protectedAgent");
|
||||
const knownTopLevelKeys = new Set([
|
||||
"agentVisibility",
|
||||
"assignmentPolicy",
|
||||
"protectedAgent",
|
||||
"managedBy",
|
||||
]);
|
||||
const hasUnknownTopLevelKey = Object.keys(policy).some((key) => !knownTopLevelKeys.has(key));
|
||||
const hasKnownPolicySection = Boolean(agentVisibility || assignmentPolicy || protectedAgent);
|
||||
if (hasUnknownTopLevelKey || !hasKnownPolicySection) {
|
||||
return {
|
||||
kind: "unknown",
|
||||
explanation: `${label} has authorization policy data that core cannot evaluate for task assignment.`,
|
||||
};
|
||||
}
|
||||
|
||||
const visibilityMode = readString(agentVisibility?.mode);
|
||||
if (visibilityMode && visibilityMode !== "discoverable" && visibilityMode !== "private") {
|
||||
return {
|
||||
kind: "unknown",
|
||||
explanation: `${label} has an unsupported agent visibility policy mode.`,
|
||||
};
|
||||
}
|
||||
|
||||
const assignmentMode = readString(assignmentPolicy?.mode);
|
||||
if (assignmentMode && assignmentMode !== "company_default" && assignmentMode !== "protected") {
|
||||
return {
|
||||
kind: "unknown",
|
||||
explanation: `${label} has an unsupported assignment policy mode.`,
|
||||
};
|
||||
}
|
||||
|
||||
const requiresApproval =
|
||||
readBoolean(protectedAgent?.requiresApproval) === true ||
|
||||
readBoolean(assignmentPolicy?.protectedAgentRequiresApproval) === true;
|
||||
if (requiresApproval) {
|
||||
return {
|
||||
kind: "requires_approval",
|
||||
explanation: `${label} requires approval before task assignment.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
visibilityMode === "private" ||
|
||||
readBoolean(agentVisibility?.hiddenFromDefaultDirectory) === true
|
||||
) {
|
||||
return {
|
||||
kind: "restricted",
|
||||
explanation: `${label} is private and cannot use simple company-wide task assignment.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (assignmentMode === "protected") {
|
||||
return {
|
||||
kind: "restricted",
|
||||
explanation: `${label} is protected and requires an explicit assignment grant.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
function agentIsInSubtree(
|
||||
agentsById: Map<string, AgentHierarchyRow>,
|
||||
rootAgentId: string,
|
||||
targetAgentId: string,
|
||||
) {
|
||||
if (rootAgentId === targetAgentId) return true;
|
||||
|
||||
let cursor: string | null = targetAgentId;
|
||||
for (let depth = 0; cursor && depth < 50; depth += 1) {
|
||||
const current = agentsById.get(cursor);
|
||||
if (!current) return false;
|
||||
if (current.reportsTo === rootAgentId) return true;
|
||||
cursor = current.reportsTo;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function loadCompanyAgentHierarchy(db: Db, companyId: string) {
|
||||
const rows = await db
|
||||
.select({ id: agents.id, reportsTo: agents.reportsTo })
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, companyId));
|
||||
return new Map(rows.map((agent) => [agent.id, agent]));
|
||||
}
|
||||
|
||||
async function isAgentInSubtree(db: Db, companyId: string, rootAgentId: string, targetAgentId: string) {
|
||||
return agentIsInSubtree(
|
||||
await loadCompanyAgentHierarchy(db, companyId),
|
||||
rootAgentId,
|
||||
targetAgentId,
|
||||
);
|
||||
}
|
||||
|
||||
async function scopeAllows(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
grantScope: Record<string, unknown> | null,
|
||||
requestedScope: Record<string, unknown> | null | undefined,
|
||||
options: { requireStructuredScope?: boolean } = {},
|
||||
) {
|
||||
if (!grantScope || Object.keys(grantScope).length === 0) return !options.requireStructuredScope;
|
||||
if (!requestedScope) return false;
|
||||
|
||||
const targetAssigneeAgentId =
|
||||
typeof requestedScope.assigneeAgentId === "string"
|
||||
? requestedScope.assigneeAgentId
|
||||
: typeof requestedScope.targetAgentId === "string"
|
||||
? requestedScope.targetAgentId
|
||||
: null;
|
||||
const requestedProjectId = typeof requestedScope.projectId === "string" ? requestedScope.projectId : null;
|
||||
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 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 };
|
||||
}
|
||||
|
||||
export function authorizationService(db: Db) {
|
||||
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
if (
|
||||
await db
|
||||
.select({ id: instanceUserRoles.id })
|
||||
.from(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getActiveMembership(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, principalType),
|
||||
eq(companyMemberships.principalId, principalId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function findGrant(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
permissionKey: PermissionKey,
|
||||
) {
|
||||
return db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, principalType),
|
||||
eq(principalPermissionGrants.principalId, principalId),
|
||||
eq(principalPermissionGrants.permissionKey, permissionKey),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function decidePrincipalGrant(input: {
|
||||
companyId: string;
|
||||
principalType: PrincipalType;
|
||||
principalId: string;
|
||||
action: AuthorizationAction;
|
||||
permissionKey: PermissionKey;
|
||||
scope?: Record<string, unknown> | null;
|
||||
}): Promise<PrincipalGrantDecision> {
|
||||
const membership = await getActiveMembership(input.companyId, input.principalType, input.principalId);
|
||||
if (!membership) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_missing_membership",
|
||||
explanation: `${input.principalType} principal ${input.principalId} is not an active member of company ${input.companyId}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const grant = await findGrant(input.companyId, input.principalType, input.principalId, input.permissionKey);
|
||||
if (!grant) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_missing_grant",
|
||||
explanation: `Missing permission: ${input.permissionKey}.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!(await scopeAllows(db, input.companyId, grant.scope, input.scope, {
|
||||
requireStructuredScope: input.permissionKey === "tasks:assign_scope",
|
||||
}))
|
||||
) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_scope",
|
||||
explanation: `Permission ${input.permissionKey} does not cover the requested scope.`,
|
||||
grant: {
|
||||
principalType: input.principalType,
|
||||
principalId: input.principalId,
|
||||
permissionKey: input.permissionKey,
|
||||
scope: grant.scope ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: `Allowed by explicit grant ${input.permissionKey}.`,
|
||||
grant: {
|
||||
principalType: input.principalType,
|
||||
principalId: input.principalId,
|
||||
permissionKey: input.permissionKey,
|
||||
scope: grant.scope ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAgent(agentId: string) {
|
||||
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 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 assignmentTargetIsInCompany(resource: AuthorizationResource) {
|
||||
if (resource.type !== "issue") return true;
|
||||
if (resource.assigneeAgentId) {
|
||||
const target = await loadAgent(resource.assigneeAgentId);
|
||||
return Boolean(
|
||||
target &&
|
||||
target.companyId === resource.companyId &&
|
||||
isSimpleAssignableAgentStatus(target.status),
|
||||
);
|
||||
}
|
||||
if (resource.assigneeUserId) {
|
||||
return Boolean(await getActiveMembership(resource.companyId, "user", resource.assigneeUserId));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function assignmentPolicyEffect(resource: AuthorizationResource): Promise<AssignmentPolicyEffect> {
|
||||
if (resource.type !== "issue") return { kind: "none" };
|
||||
|
||||
const checks: Array<Promise<AssignmentPolicyEffect>> = [];
|
||||
if (resource.assigneeAgentId) {
|
||||
checks.push(
|
||||
loadAgent(resource.assigneeAgentId).then((agent) =>
|
||||
evaluateAuthorizationPolicyForAssignment(
|
||||
readPolicyObject(agent?.permissions, "authorizationPolicy"),
|
||||
"Target agent",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (resource.projectId) {
|
||||
checks.push(
|
||||
loadProjectAuthorizationPolicy(resource.companyId, resource.projectId).then((policy) =>
|
||||
evaluateAuthorizationPolicyForAssignment(policy, "Target project"),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (resource.issueId) {
|
||||
checks.push(
|
||||
loadIssueAuthorizationPolicy(resource.companyId, resource.issueId).then((policy) =>
|
||||
evaluateAuthorizationPolicyForAssignment(policy, "Target issue"),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (resource.parentIssueId && resource.parentIssueId !== resource.issueId) {
|
||||
checks.push(
|
||||
loadIssueAuthorizationPolicy(resource.companyId, resource.parentIssueId).then((policy) =>
|
||||
evaluateAuthorizationPolicyForAssignment(policy, "Parent issue"),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (checks.length === 0) return { kind: "none" };
|
||||
|
||||
const effects = await Promise.all(checks);
|
||||
return (
|
||||
effects.find((effect) => effect.kind === "unknown") ??
|
||||
effects.find((effect) => effect.kind === "requires_approval") ??
|
||||
effects.find((effect) => effect.kind === "restricted") ??
|
||||
{ kind: "none" }
|
||||
);
|
||||
}
|
||||
|
||||
async function isManagerOf(companyId: string, managerAgentId: string, assigneeAgentId: string) {
|
||||
return isAgentInSubtree(db, companyId, managerAgentId, assigneeAgentId);
|
||||
}
|
||||
|
||||
async function decide(input: {
|
||||
actor: AuthorizationActor;
|
||||
action: AuthorizationAction;
|
||||
resource: AuthorizationResource;
|
||||
scope?: Record<string, unknown> | null;
|
||||
}): Promise<AuthorizationDecision> {
|
||||
const permissionKey = permissionForAction(input.action);
|
||||
const companyId = companyIdForResource(input.resource);
|
||||
|
||||
async function decideWithTaskAssignmentGrants(
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
): Promise<AuthorizationDecision> {
|
||||
const broadDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: "tasks:assign",
|
||||
scope: input.scope,
|
||||
});
|
||||
if (broadDecision.allowed || broadDecision.reason === "deny_missing_membership") return broadDecision;
|
||||
const scopedDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: "tasks:assign_scope",
|
||||
scope: input.scope,
|
||||
});
|
||||
if (scopedDecision.allowed || broadDecision.reason === "deny_missing_grant") return scopedDecision;
|
||||
return broadDecision;
|
||||
}
|
||||
|
||||
async function denyForAssignmentPolicyIfNeeded(
|
||||
policyEffect: AssignmentPolicyEffect,
|
||||
): Promise<AuthorizationDecision | null> {
|
||||
if (policyEffect.kind === "none" || policyEffect.kind === "restricted") return null;
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_policy_restricted",
|
||||
explanation: policyEffect.explanation,
|
||||
});
|
||||
}
|
||||
|
||||
function denyRestrictedAssignmentPolicy(policyEffect: AssignmentPolicyEffect): AuthorizationDecision {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_policy_restricted",
|
||||
explanation:
|
||||
policyEffect.kind === "restricted"
|
||||
? policyEffect.explanation
|
||||
: "Restrictive authorization policy blocks simple company-wide task assignment.",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.actor.type === "none") {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_unauthenticated",
|
||||
explanation: "Authentication required.",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.actor.type === "board") {
|
||||
let taskAssignmentPolicyEffect: AssignmentPolicyEffect | null = null;
|
||||
if (input.actor.source === "local_implicit") {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_local_board",
|
||||
explanation: "Allowed because the actor is the local implicit board.",
|
||||
});
|
||||
}
|
||||
if (input.actor.isInstanceAdmin || 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.actor.userId) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_unauthenticated",
|
||||
explanation: "Board user id is required.",
|
||||
});
|
||||
}
|
||||
if (input.action === "tasks:assign") {
|
||||
if (!(await assignmentTargetIsInCompany(input.resource))) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_company_boundary",
|
||||
explanation: "Task assignment target agent is not active in the target company.",
|
||||
});
|
||||
}
|
||||
const policyEffect = await assignmentPolicyEffect(input.resource);
|
||||
taskAssignmentPolicyEffect = policyEffect;
|
||||
const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect);
|
||||
if (policyDeny) return policyDeny;
|
||||
const membership = await getActiveMembership(companyId, "user", input.actor.userId);
|
||||
if (policyEffect.kind === "none" && membership && membership.membershipRole !== "viewer") {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_simple_company_member",
|
||||
explanation: "Allowed by simple mode company-wide task assignment default.",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!permissionKey) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_unsupported_action",
|
||||
explanation: `No board permission mapping exists for ${input.action}.`,
|
||||
});
|
||||
}
|
||||
if (input.action === "tasks:assign") {
|
||||
const grantDecision = await decideWithTaskAssignmentGrants("user", input.actor.userId);
|
||||
if (grantDecision.allowed) return grantDecision;
|
||||
const policyEffect = taskAssignmentPolicyEffect ?? await assignmentPolicyEffect(input.resource);
|
||||
if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect);
|
||||
return grantDecision;
|
||||
}
|
||||
return decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: input.actor.userId,
|
||||
action: input.action,
|
||||
permissionKey,
|
||||
scope: input.scope,
|
||||
});
|
||||
}
|
||||
|
||||
const actorAgentId = input.actor.agentId ?? null;
|
||||
if (!actorAgentId) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_unauthenticated",
|
||||
explanation: "Agent authentication required.",
|
||||
});
|
||||
}
|
||||
if (input.actor.companyId !== companyId) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_company_boundary",
|
||||
explanation: "Agent key cannot access another company.",
|
||||
});
|
||||
}
|
||||
|
||||
const actorAgent = await loadAgent(actorAgentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_company_boundary",
|
||||
explanation: "Actor agent was not found in the target company.",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === "tasks:assign") {
|
||||
if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_missing_membership",
|
||||
explanation: "Actor agent is not active for simple mode task assignment.",
|
||||
});
|
||||
}
|
||||
if (!(await assignmentTargetIsInCompany(input.resource))) {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_company_boundary",
|
||||
explanation: "Task assignment target agent is not active in the target company.",
|
||||
});
|
||||
}
|
||||
const policyEffect = await assignmentPolicyEffect(input.resource);
|
||||
const policyDeny = await denyForAssignmentPolicyIfNeeded(policyEffect);
|
||||
if (policyDeny) return policyDeny;
|
||||
if (policyEffect.kind === "restricted") {
|
||||
const grantDecision = await decideWithTaskAssignmentGrants("agent", actorAgentId);
|
||||
if (grantDecision.allowed) return grantDecision;
|
||||
return denyRestrictedAssignmentPolicy(policyEffect);
|
||||
}
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_simple_company_member",
|
||||
explanation: "Allowed by simple mode company-wide task assignment default.",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === "issue: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 === "agent_config:update" &&
|
||||
input.resource.type === "agent" &&
|
||||
input.resource.agentId === actorAgentId
|
||||
) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_self",
|
||||
explanation: "Allowed because the actor is updating its own agent configuration.",
|
||||
});
|
||||
}
|
||||
|
||||
if (permissionKey) {
|
||||
const grantDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType: "agent",
|
||||
principalId: actorAgentId,
|
||||
action: input.action,
|
||||
permissionKey,
|
||||
scope: input.scope,
|
||||
});
|
||||
if (grantDecision.allowed) return grantDecision;
|
||||
}
|
||||
|
||||
if (
|
||||
(input.action === "agents:create" ||
|
||||
input.action === "agent_config:read" ||
|
||||
input.action === "agent_config:update" ||
|
||||
input.action === "tasks:manage_active_checkouts") &&
|
||||
canCreateAgentsLegacy(actorAgent)
|
||||
) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_legacy_agent_creator",
|
||||
explanation: "Allowed by legacy agent creator authority.",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
input.action === "tasks:manage_active_checkouts" &&
|
||||
input.resource.type === "issue" &&
|
||||
input.resource.assigneeAgentId &&
|
||||
await isManagerOf(companyId, actorAgentId, input.resource.assigneeAgentId)
|
||||
) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_manager_chain",
|
||||
explanation: "Allowed because the actor manages the issue assignee in the reporting chain.",
|
||||
});
|
||||
}
|
||||
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_missing_grant",
|
||||
explanation: permissionKey
|
||||
? `Missing permission: ${permissionKey}.`
|
||||
: `No agent permission mapping exists for ${input.action}.`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
decide,
|
||||
decidePrincipalGrant,
|
||||
};
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ export function grantsForHumanRole(
|
|||
case "owner":
|
||||
return [
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "environments:manage", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
{ permissionKey: "users:manage_permissions", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
|
|
@ -36,6 +37,7 @@ export function grantsForHumanRole(
|
|||
case "admin":
|
||||
return [
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "environments:manage", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
{ permissionKey: "tasks:assign", scope: null },
|
||||
{ permissionKey: "joins:approve", scope: null },
|
||||
|
|
|
|||
|
|
@ -4118,7 +4118,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||
if (mode === "agent_safe" && options?.sourceCompanyId) {
|
||||
await access.copyActiveUserMemberships(options.sourceCompanyId, created.id);
|
||||
} else {
|
||||
await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active");
|
||||
const ownerPrincipalId = actorUserId ?? "board";
|
||||
await access.ensureMembership(created.id, "user", ownerPrincipalId, "owner", "active");
|
||||
await access.ensureRoleDefaultGrants(
|
||||
created.id,
|
||||
ownerPrincipalId,
|
||||
"owner",
|
||||
actorUserId ?? null,
|
||||
);
|
||||
}
|
||||
targetCompany = created;
|
||||
companyAction = "created";
|
||||
|
|
|
|||
|
|
@ -44,6 +44,19 @@ export { sidebarBadgeService } from "./sidebar-badges.js";
|
|||
export { sidebarPreferenceService } from "./sidebar-preferences.js";
|
||||
export { inboxDismissalService } from "./inbox-dismissals.js";
|
||||
export { accessService } from "./access.js";
|
||||
export {
|
||||
backfillPrincipalAccessCompatibility,
|
||||
ensureHumanRoleDefaultGrants,
|
||||
insertMissingPrincipalGrants,
|
||||
type PrincipalAccessCompatibilityBackfillStats,
|
||||
} from "./principal-access-compatibility.js";
|
||||
export { authorizationService } from "./authorization.js";
|
||||
export type {
|
||||
AuthorizationAction,
|
||||
AuthorizationActor,
|
||||
AuthorizationDecision,
|
||||
AuthorizationResource,
|
||||
} from "./authorization.js";
|
||||
export { boardAuthService } from "./board-auth.js";
|
||||
export { instanceSettingsService } from "./instance-settings.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
|
|||
commentAnnotation: "ui.commentAnnotation.register",
|
||||
commentContextMenuItem: "ui.action.register",
|
||||
settingsPage: "instance.settings.register",
|
||||
companySettingsPage: "instance.settings.register",
|
||||
routeSidebar: "ui.sidebar.register",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agentTaskSessions as agentTaskSessionsTable,
|
||||
agents as agentsTable,
|
||||
budgetIncidents,
|
||||
costEvents,
|
||||
heartbeatRuns,
|
||||
invites,
|
||||
issues as issuesTable,
|
||||
pluginLogs,
|
||||
principalPermissionGrants,
|
||||
projects as projectsTable,
|
||||
} from "@paperclipai/db";
|
||||
import { eq, and, like, desc, inArray, sql } from "drizzle-orm";
|
||||
import { eq, and, like, desc, inArray, sql, isNull, isNotNull, gt, lte } from "drizzle-orm";
|
||||
import type {
|
||||
HostServices,
|
||||
Company,
|
||||
|
|
@ -22,7 +26,7 @@ import type {
|
|||
PluginIssueOrchestrationSummary,
|
||||
PluginExecutionWorkspaceMetadata,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared";
|
||||
import type { CreateIssueThreadInteraction, InviteJoinType, IssueDocumentSummary, PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
|
||||
import { companyService } from "./companies.js";
|
||||
import { agentService } from "./agents.js";
|
||||
|
|
@ -36,11 +40,8 @@ import { heartbeatService } from "./heartbeat.js";
|
|||
import { budgetService } from "./budgets.js";
|
||||
import { issueApprovalService } from "./issue-approvals.js";
|
||||
import { subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { activityService } from "./activity.js";
|
||||
import { costService } from "./costs.js";
|
||||
import { assetService } from "./assets.js";
|
||||
import { pluginRegistryService } from "./plugin-registry.js";
|
||||
import { pluginStateStore } from "./plugin-state-store.js";
|
||||
import { pluginDatabaseService } from "./plugin-database.js";
|
||||
|
|
@ -71,6 +72,9 @@ import { request as httpsRequest } from "node:https";
|
|||
import { isIP } from "node:net";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { accessService } from "./access.js";
|
||||
import { authorizationService, type AuthorizationActor } from "./authorization.js";
|
||||
import { sanitizeRecord } from "../redaction.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSRF protection for plugin HTTP fetch
|
||||
|
|
@ -526,11 +530,10 @@ export function buildHostServices(
|
|||
const issues = issueService(db);
|
||||
const documents = documentService(db);
|
||||
const goals = goalService(db);
|
||||
const activity = activityService(db);
|
||||
const costs = costService(db);
|
||||
const access = accessService(db);
|
||||
const authorization = authorizationService(db);
|
||||
const budgets = budgetService(db);
|
||||
const issueApprovals = issueApprovalService(db);
|
||||
const assets = assetService(db);
|
||||
const scopedBus = eventBus.forPlugin(pluginKey);
|
||||
|
||||
// Track active session event subscriptions for cleanup
|
||||
|
|
@ -562,6 +565,17 @@ export function buildHostServices(
|
|||
return rows.slice(offset, offset + limit);
|
||||
};
|
||||
|
||||
const authorizationAuditDecisionCondition = (decisionFilter: string) => {
|
||||
const conditions = [
|
||||
sql`lower(${activityLog.details}->>'decision') = ${decisionFilter}`,
|
||||
decisionFilter === "allow" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 6) = 'allow_'` : undefined,
|
||||
decisionFilter === "deny" ? sql`left(coalesce(${activityLog.details}->>'reason', ''), 5) = 'deny_'` : undefined,
|
||||
decisionFilter === "allow" ? sql`${activityLog.details}->>'allowed' = 'true'` : undefined,
|
||||
decisionFilter === "deny" ? sql`${activityLog.details}->>'allowed' = 'false'` : undefined,
|
||||
].filter((condition): condition is NonNullable<typeof condition> => Boolean(condition));
|
||||
return sql`(${sql.join(conditions, sql` OR `)})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugins are instance-wide in the current runtime. Company IDs are still
|
||||
* required for company-scoped data access, but there is no per-company
|
||||
|
|
@ -841,6 +855,202 @@ export function buildHostServices(
|
|||
}));
|
||||
};
|
||||
|
||||
const INVITE_TOKEN_PREFIX = "pcp_invite_";
|
||||
const INVITE_TOKEN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const INVITE_TOKEN_SUFFIX_LENGTH = 8;
|
||||
const INVITE_TOKEN_MAX_RETRIES = 5;
|
||||
const COMPANY_INVITE_TTL_MS = 72 * 60 * 60 * 1000;
|
||||
|
||||
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex");
|
||||
|
||||
const createInviteToken = () => {
|
||||
const bytes = randomBytes(INVITE_TOKEN_SUFFIX_LENGTH);
|
||||
let suffix = "";
|
||||
for (let idx = 0; idx < INVITE_TOKEN_SUFFIX_LENGTH; idx += 1) {
|
||||
suffix += INVITE_TOKEN_ALPHABET[bytes[idx]! % INVITE_TOKEN_ALPHABET.length];
|
||||
}
|
||||
return `${INVITE_TOKEN_PREFIX}${suffix}`;
|
||||
};
|
||||
|
||||
const isInviteTokenHashCollisionError = (error: unknown) => {
|
||||
const candidates = [
|
||||
error,
|
||||
(error as { cause?: unknown } | null)?.cause ?? null,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== "object") continue;
|
||||
const code = "code" in candidate && typeof candidate.code === "string" ? candidate.code : null;
|
||||
const message = "message" in candidate && typeof candidate.message === "string" ? candidate.message : "";
|
||||
const constraint = "constraint" in candidate && typeof candidate.constraint === "string" ? candidate.constraint : null;
|
||||
if (code !== "23505") continue;
|
||||
if (constraint === "invites_token_hash_unique_idx") return true;
|
||||
if (message.includes("invites_token_hash_unique_idx")) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const inviteState = (invite: typeof invites.$inferSelect) => {
|
||||
if (invite.revokedAt) return "revoked" as const;
|
||||
if (invite.acceptedAt) return "accepted" as const;
|
||||
if (invite.expiresAt <= new Date()) return "expired" as const;
|
||||
return "active" as const;
|
||||
};
|
||||
|
||||
const redactInvite = (invite: typeof invites.$inferSelect) => {
|
||||
const { tokenHash: _tokenHash, defaultsPayload, ...safeInvite } = invite;
|
||||
return {
|
||||
...safeInvite,
|
||||
allowedJoinTypes: safeInvite.allowedJoinTypes as InviteJoinType,
|
||||
defaultsPayload: defaultsPayload && typeof defaultsPayload === "object"
|
||||
? sanitizeRecord(defaultsPayload)
|
||||
: defaultsPayload ?? null,
|
||||
state: inviteState(invite),
|
||||
};
|
||||
};
|
||||
|
||||
const inviteStateWhereClause = (state: unknown) => {
|
||||
const now = new Date();
|
||||
switch (state) {
|
||||
case "active":
|
||||
return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), gt(invites.expiresAt, now));
|
||||
case "accepted":
|
||||
return isNotNull(invites.acceptedAt);
|
||||
case "expired":
|
||||
return and(isNull(invites.revokedAt), isNull(invites.acceptedAt), lte(invites.expiresAt, now));
|
||||
case "revoked":
|
||||
return isNotNull(invites.revokedAt);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const mergeInviteDefaults = (defaultsPayload: Record<string, unknown> | null | undefined, agentMessage: string | null, humanRole: string | null) => {
|
||||
const defaults = defaultsPayload && typeof defaultsPayload === "object"
|
||||
? { ...defaultsPayload }
|
||||
: {};
|
||||
if (humanRole) {
|
||||
defaults.human = {
|
||||
...(typeof defaults.human === "object" && defaults.human !== null ? defaults.human as Record<string, unknown> : {}),
|
||||
role: humanRole,
|
||||
};
|
||||
}
|
||||
if (agentMessage) {
|
||||
defaults.agent = {
|
||||
...(typeof defaults.agent === "object" && defaults.agent !== null ? defaults.agent as Record<string, unknown> : {}),
|
||||
message: agentMessage,
|
||||
};
|
||||
}
|
||||
return sanitizeRecord(defaults);
|
||||
};
|
||||
|
||||
const redactGrant = (grant: typeof principalPermissionGrants.$inferSelect) => ({
|
||||
...grant,
|
||||
principalType: grant.principalType as PrincipalType,
|
||||
permissionKey: grant.permissionKey as PermissionKey,
|
||||
scope: grant.scope && typeof grant.scope === "object" ? sanitizeRecord(grant.scope) : grant.scope ?? null,
|
||||
});
|
||||
|
||||
const loadPluginMember = async (companyId: string, memberId: string) => {
|
||||
const member = await access.getMemberById(companyId, memberId);
|
||||
if (!member) return null;
|
||||
const grants = await access.listPrincipalGrants(
|
||||
companyId,
|
||||
member.principalType as PrincipalType,
|
||||
member.principalId,
|
||||
);
|
||||
return {
|
||||
...member,
|
||||
principalType: member.principalType as PrincipalType,
|
||||
status: member.status as "pending" | "active" | "suspended" | "archived",
|
||||
grants: grants.map(redactGrant),
|
||||
};
|
||||
};
|
||||
|
||||
const pluginAssignmentActor = (actor: {
|
||||
type: "agent" | "board";
|
||||
agentId?: string | null;
|
||||
companyId?: string | null;
|
||||
userId?: string | null;
|
||||
companyIds?: string[];
|
||||
}): AuthorizationActor => {
|
||||
if (actor.type === "agent") {
|
||||
return {
|
||||
type: "agent",
|
||||
agentId: actor.agentId ?? null,
|
||||
companyId: actor.companyId ?? null,
|
||||
source: "agent_key",
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "board",
|
||||
userId: actor.userId ?? null,
|
||||
companyIds: Array.isArray(actor.companyIds) ? actor.companyIds : [],
|
||||
source: "session",
|
||||
};
|
||||
};
|
||||
|
||||
const policyPathForResource = (resourceType: "company" | "agent" | "project" | "issue") => {
|
||||
switch (resourceType) {
|
||||
case "agent":
|
||||
return { table: "agent" as const };
|
||||
case "project":
|
||||
return { table: "project" as const };
|
||||
case "issue":
|
||||
return { table: "issue" as const };
|
||||
case "company":
|
||||
return { table: "company" as const };
|
||||
}
|
||||
};
|
||||
|
||||
const readAuthorizationPolicy = async (companyId: string, resourceType: "company" | "agent" | "project" | "issue", resourceId: string) => {
|
||||
const pathInfo = policyPathForResource(resourceType);
|
||||
if (pathInfo.table === "agent") {
|
||||
const agent = await agents.getById(resourceId);
|
||||
if (!inCompany(agent, companyId)) return null;
|
||||
const permissions = agent.permissions && typeof agent.permissions === "object" ? agent.permissions as Record<string, unknown> : {};
|
||||
return {
|
||||
resourceType,
|
||||
resourceId,
|
||||
companyId,
|
||||
policy: permissions.authorizationPolicy && typeof permissions.authorizationPolicy === "object"
|
||||
? sanitizeRecord(permissions.authorizationPolicy as Record<string, unknown>)
|
||||
: null,
|
||||
updatedAt: agent.updatedAt,
|
||||
};
|
||||
}
|
||||
if (pathInfo.table === "project") {
|
||||
const project = await projects.getById(resourceId);
|
||||
if (!inCompany(project, companyId)) return null;
|
||||
const policy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object"
|
||||
? (project.executionWorkspacePolicy as unknown as Record<string, unknown>).authorizationPolicy
|
||||
: null;
|
||||
return {
|
||||
resourceType,
|
||||
resourceId,
|
||||
companyId,
|
||||
policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record<string, unknown>) : null,
|
||||
updatedAt: project.updatedAt,
|
||||
};
|
||||
}
|
||||
if (pathInfo.table === "issue") {
|
||||
const issue = await issues.getById(resourceId);
|
||||
if (!inCompany(issue, companyId)) return null;
|
||||
const policy = issue.executionPolicy && typeof issue.executionPolicy === "object"
|
||||
? (issue.executionPolicy as Record<string, unknown>).authorizationPolicy
|
||||
: null;
|
||||
return {
|
||||
resourceType,
|
||||
resourceId,
|
||||
companyId,
|
||||
policy: policy && typeof policy === "object" ? sanitizeRecord(policy as Record<string, unknown>) : null,
|
||||
updatedAt: issue.updatedAt,
|
||||
};
|
||||
}
|
||||
const company = await companies.getById(resourceId);
|
||||
if (!company || company.id !== companyId) return null;
|
||||
return { resourceType, resourceId, companyId, policy: null, updatedAt: company.updatedAt };
|
||||
};
|
||||
|
||||
return {
|
||||
config: {
|
||||
async get() {
|
||||
|
|
@ -1993,6 +2203,337 @@ export function buildHostServices(
|
|||
},
|
||||
},
|
||||
|
||||
access: {
|
||||
async listMembers(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const rows = await access.listMembers(companyId);
|
||||
const visibleRows = params.includeArchived ? rows : rows.filter((row) => row.status !== "archived");
|
||||
const grants = await db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(eq(principalPermissionGrants.companyId, companyId));
|
||||
const grantsByPrincipal = new Map<string, typeof grants>();
|
||||
for (const grant of grants) {
|
||||
const key = `${grant.principalType}:${grant.principalId}`;
|
||||
const existing = grantsByPrincipal.get(key) ?? [];
|
||||
existing.push(grant);
|
||||
grantsByPrincipal.set(key, existing);
|
||||
}
|
||||
return visibleRows.map((member) => ({
|
||||
...member,
|
||||
principalType: member.principalType as PrincipalType,
|
||||
status: member.status as "pending" | "active" | "suspended" | "archived",
|
||||
grants: (grantsByPrincipal.get(`${member.principalType}:${member.principalId}`) ?? []).map(redactGrant),
|
||||
}));
|
||||
},
|
||||
async getMember(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return loadPluginMember(companyId, params.memberId);
|
||||
},
|
||||
async updateMember(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const updated = await access.updateMember(companyId, params.memberId, params.patch);
|
||||
if (!updated) throw new Error("Member not found");
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "company_member.updated_by_plugin",
|
||||
entityType: "company_membership",
|
||||
entityId: params.memberId,
|
||||
details: {
|
||||
patch: sanitizeRecord(params.patch as Record<string, unknown>),
|
||||
},
|
||||
});
|
||||
return (await loadPluginMember(companyId, params.memberId))!;
|
||||
},
|
||||
async listInvites(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const limit = Math.min(Math.max(Number(params.limit ?? 20), 1), 100);
|
||||
const offset = Math.max(Number(params.offset ?? 0), 0);
|
||||
const stateClause = inviteStateWhereClause(params.state);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(stateClause ? and(eq(invites.companyId, companyId), stateClause) : eq(invites.companyId, companyId))
|
||||
.orderBy(desc(invites.createdAt))
|
||||
.limit(limit + 1)
|
||||
.offset(offset);
|
||||
const hasMore = rows.length > limit;
|
||||
return {
|
||||
invites: rows.slice(0, limit).map(redactInvite),
|
||||
nextOffset: hasMore ? offset + limit : null,
|
||||
};
|
||||
},
|
||||
async createInvite(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const normalizedAgentMessage = typeof params.agentMessage === "string"
|
||||
? params.agentMessage.trim() || null
|
||||
: null;
|
||||
const allowedJoinTypes = params.allowedJoinTypes ?? "both";
|
||||
const humanRole = allowedJoinTypes === "agent" ? null : params.humanRole ?? "operator";
|
||||
const insertValues = {
|
||||
companyId,
|
||||
inviteType: "company_join" as const,
|
||||
allowedJoinTypes,
|
||||
defaultsPayload: mergeInviteDefaults(params.defaultsPayload ?? null, normalizedAgentMessage, humanRole),
|
||||
expiresAt: new Date(Date.now() + COMPANY_INVITE_TTL_MS),
|
||||
invitedByUserId: null,
|
||||
};
|
||||
let token: string | null = null;
|
||||
let created: typeof invites.$inferSelect | null = null;
|
||||
for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
|
||||
const candidateToken = createInviteToken();
|
||||
try {
|
||||
created = await db
|
||||
.insert(invites)
|
||||
.values({
|
||||
...insertValues,
|
||||
tokenHash: hashToken(candidateToken),
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
token = candidateToken;
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!isInviteTokenHashCollisionError(error)) throw error;
|
||||
}
|
||||
}
|
||||
if (!token || !created) throw new Error("Failed to generate a unique invite token");
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "invite.created_by_plugin",
|
||||
entityType: "invite",
|
||||
entityId: created.id,
|
||||
details: {
|
||||
allowedJoinTypes: created.allowedJoinTypes,
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
hasAgentMessage: Boolean(normalizedAgentMessage),
|
||||
},
|
||||
});
|
||||
return { ...redactInvite(created), token };
|
||||
},
|
||||
async revokeInvite(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const invite = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(and(eq(invites.id, params.inviteId), eq(invites.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!invite) throw new Error("Invite not found");
|
||||
if (invite.acceptedAt) throw new Error("Invite already consumed");
|
||||
if (invite.revokedAt) return redactInvite(invite);
|
||||
const revoked = await db
|
||||
.update(invites)
|
||||
.set({ revokedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(invites.id, invite.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? invite);
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "invite.revoked_by_plugin",
|
||||
entityType: "invite",
|
||||
entityId: invite.id,
|
||||
});
|
||||
return redactInvite(revoked);
|
||||
},
|
||||
},
|
||||
|
||||
authorization: {
|
||||
async listGrants(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const conditions = [
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
params.principalType ? eq(principalPermissionGrants.principalType, params.principalType) : undefined,
|
||||
params.principalId ? eq(principalPermissionGrants.principalId, params.principalId) : undefined,
|
||||
].filter((condition): condition is NonNullable<typeof condition> => Boolean(condition));
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(and(...conditions))
|
||||
.orderBy(principalPermissionGrants.principalType, principalPermissionGrants.principalId, principalPermissionGrants.permissionKey);
|
||||
return rows.map(redactGrant);
|
||||
},
|
||||
async setGrants(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
if (params.principalType !== "agent" && params.principalType !== "user") {
|
||||
throw new Error("principalType must be 'agent' or 'user'");
|
||||
}
|
||||
if (params.principalType === "agent") {
|
||||
requireInCompany("Agent", await agents.getById(params.principalId), companyId);
|
||||
} else {
|
||||
const membership = await access.getMembership(companyId, params.principalType as PrincipalType, params.principalId);
|
||||
if (!membership) throw new Error("Principal is not a member of this company");
|
||||
}
|
||||
await access.setPrincipalGrants(
|
||||
companyId,
|
||||
params.principalType as PrincipalType,
|
||||
params.principalId,
|
||||
params.grants.map((grant) => ({
|
||||
permissionKey: grant.permissionKey as PermissionKey,
|
||||
scope: grant.scope ? sanitizeRecord(grant.scope) : null,
|
||||
})),
|
||||
params.grantedByUserId ?? null,
|
||||
);
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "authorization.grants_updated_by_plugin",
|
||||
entityType: "principal_permission_grants",
|
||||
entityId: `${params.principalType}:${params.principalId}`,
|
||||
details: { grantCount: params.grants.length },
|
||||
});
|
||||
return access
|
||||
.listPrincipalGrants(companyId, params.principalType as PrincipalType, params.principalId)
|
||||
.then((rows) => rows.map(redactGrant));
|
||||
},
|
||||
async policySummary(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const [members, grants] = await Promise.all([
|
||||
access.listMembers(companyId),
|
||||
db
|
||||
.select({ id: principalPermissionGrants.id })
|
||||
.from(principalPermissionGrants)
|
||||
.where(eq(principalPermissionGrants.companyId, companyId)),
|
||||
]);
|
||||
return {
|
||||
companyId,
|
||||
permissionsMode: "simple" as const,
|
||||
memberCount: members.length,
|
||||
activeMemberCount: members.filter((member) => member.status === "active").length,
|
||||
grantCount: grants.length,
|
||||
advancedPolicyAvailable: false as const,
|
||||
};
|
||||
},
|
||||
async getPolicy(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return readAuthorizationPolicy(companyId, params.resourceType, params.resourceId);
|
||||
},
|
||||
async updatePolicy(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const policy = params.policy ? sanitizeRecord(params.policy) : null;
|
||||
if (params.resourceType === "agent") {
|
||||
const agent = requireInCompany("Agent", await agents.getById(params.resourceId), companyId);
|
||||
const permissions = agent.permissions && typeof agent.permissions === "object"
|
||||
? { ...(agent.permissions as Record<string, unknown>) }
|
||||
: {};
|
||||
if (policy) permissions.authorizationPolicy = policy;
|
||||
else delete permissions.authorizationPolicy;
|
||||
await db
|
||||
.update(agentsTable)
|
||||
.set({ permissions, updatedAt: new Date() })
|
||||
.where(eq(agentsTable.id, agent.id));
|
||||
} else if (params.resourceType === "project") {
|
||||
const project = requireInCompany("Project", await projects.getById(params.resourceId), companyId);
|
||||
const executionWorkspacePolicy = project.executionWorkspacePolicy && typeof project.executionWorkspacePolicy === "object"
|
||||
? { ...(project.executionWorkspacePolicy as unknown as Record<string, unknown>) }
|
||||
: {};
|
||||
if (policy) executionWorkspacePolicy.authorizationPolicy = policy;
|
||||
else delete executionWorkspacePolicy.authorizationPolicy;
|
||||
await db
|
||||
.update(projectsTable)
|
||||
.set({ executionWorkspacePolicy, updatedAt: new Date() })
|
||||
.where(eq(projectsTable.id, project.id));
|
||||
} else if (params.resourceType === "issue") {
|
||||
const issue = requireInCompany("Issue", await issues.getById(params.resourceId), companyId);
|
||||
const executionPolicy = issue.executionPolicy && typeof issue.executionPolicy === "object"
|
||||
? { ...(issue.executionPolicy as Record<string, unknown>) }
|
||||
: {};
|
||||
if (policy) executionPolicy.authorizationPolicy = policy;
|
||||
else delete executionPolicy.authorizationPolicy;
|
||||
await db
|
||||
.update(issuesTable)
|
||||
.set({ executionPolicy, updatedAt: new Date() })
|
||||
.where(eq(issuesTable.id, issue.id));
|
||||
} else {
|
||||
const company = await companies.getById(params.resourceId);
|
||||
if (!company || company.id !== companyId) throw new Error("Company not found");
|
||||
throw new Error("Company authorization policy updates are not supported by the current core schema");
|
||||
}
|
||||
await logPluginActivity({
|
||||
companyId,
|
||||
action: "authorization.policy_updated_by_plugin",
|
||||
entityType: params.resourceType,
|
||||
entityId: params.resourceId,
|
||||
details: { hasPolicy: Boolean(policy) },
|
||||
});
|
||||
const updated = await readAuthorizationPolicy(companyId, params.resourceType, params.resourceId);
|
||||
if (!updated) throw new Error("Policy resource not found");
|
||||
return updated;
|
||||
},
|
||||
async previewAssignment(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return authorization.decide({
|
||||
actor: pluginAssignmentActor(params.actor),
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId, ...params.target },
|
||||
scope: {
|
||||
issueId: params.target.issueId ?? null,
|
||||
projectId: params.target.projectId ?? null,
|
||||
parentIssueId: params.target.parentIssueId ?? null,
|
||||
assigneeAgentId: params.target.assigneeAgentId ?? null,
|
||||
assigneeUserId: params.target.assigneeUserId ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
async explainAssignment(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return authorization.decide({
|
||||
actor: pluginAssignmentActor(params.actor),
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId, ...params.target },
|
||||
scope: {
|
||||
issueId: params.target.issueId ?? null,
|
||||
projectId: params.target.projectId ?? null,
|
||||
parentIssueId: params.target.parentIssueId ?? null,
|
||||
assigneeAgentId: params.target.assigneeAgentId ?? null,
|
||||
assigneeUserId: params.target.assigneeUserId ?? null,
|
||||
},
|
||||
});
|
||||
},
|
||||
async searchAudit(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
const limit = Math.min(Math.max(Number(params.limit ?? 50), 1), 100);
|
||||
const offset = Math.max(Number(params.offset ?? 0), 0);
|
||||
const decisionFilter = typeof params.decision === "string" && params.decision.trim()
|
||||
? params.decision.trim().toLowerCase()
|
||||
: null;
|
||||
const conditions = [
|
||||
eq(activityLog.companyId, companyId),
|
||||
params.action ? eq(activityLog.action, params.action) : undefined,
|
||||
params.actorType ? eq(activityLog.actorType, params.actorType) : undefined,
|
||||
params.actorId ? eq(activityLog.actorId, params.actorId) : undefined,
|
||||
params.entityType ? eq(activityLog.entityType, params.entityType) : undefined,
|
||||
params.entityId ? eq(activityLog.entityId, params.entityId) : undefined,
|
||||
decisionFilter ? authorizationAuditDecisionCondition(decisionFilter) : undefined,
|
||||
].filter((condition): condition is NonNullable<typeof condition> => Boolean(condition));
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(activityLog.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
details: row.details && typeof row.details === "object"
|
||||
? sanitizeRecord(row.details)
|
||||
: row.details ?? null,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
agentSessions: {
|
||||
async create(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
*/
|
||||
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
|
|
@ -39,9 +40,12 @@ import {
|
|||
} from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
JsonRpcId,
|
||||
PluginInvocationContext,
|
||||
PluginInvocationScope,
|
||||
JsonRpcResponse,
|
||||
JsonRpcRequest,
|
||||
JsonRpcNotification,
|
||||
WorkerHostCallContext,
|
||||
HostToWorkerMethodName,
|
||||
HostToWorkerMethods,
|
||||
WorkerToHostMethodName,
|
||||
|
|
@ -108,6 +112,7 @@ export type WorkerStatus =
|
|||
*/
|
||||
export type WorkerToHostHandler<M extends WorkerToHostMethodName> = (
|
||||
params: WorkerToHostMethods[M][0],
|
||||
context?: WorkerHostCallContext,
|
||||
) => Promise<WorkerToHostMethods[M][1]>;
|
||||
|
||||
/**
|
||||
|
|
@ -201,6 +206,11 @@ interface PendingRequest {
|
|||
sentAt: number;
|
||||
}
|
||||
|
||||
interface ActiveInvocation {
|
||||
scope: PluginInvocationScope;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginWorkerHandle — manages a single worker process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -379,6 +389,7 @@ export function createPluginWorkerHandle(
|
|||
// Pending RPC requests awaiting a response
|
||||
const pendingRequests = new Map<string | number, PendingRequest>();
|
||||
let nextRequestId = 1;
|
||||
const activeInvocations = new Map<string, ActiveInvocation>();
|
||||
|
||||
// Optional methods reported by the worker during initialization
|
||||
let supportedMethods: string[] = [];
|
||||
|
|
@ -475,13 +486,78 @@ export function createPluginWorkerHandle(
|
|||
pending.resolve(response);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function deriveInvocationScope(
|
||||
method: HostToWorkerMethodName | string,
|
||||
params: unknown,
|
||||
): PluginInvocationScope | null {
|
||||
if (!isRecord(params)) return null;
|
||||
|
||||
const directCompanyId = readNonEmptyString(params.companyId);
|
||||
if (directCompanyId) return { companyId: directCompanyId };
|
||||
|
||||
if (method === "executeTool" && isRecord(params.runContext)) {
|
||||
const companyId = readNonEmptyString(params.runContext.companyId);
|
||||
return companyId ? { companyId } : null;
|
||||
}
|
||||
|
||||
if (method === "onEvent" && isRecord(params.event)) {
|
||||
const companyId = readNonEmptyString(params.event.companyId);
|
||||
return companyId ? { companyId } : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function registerInvocation(scope: PluginInvocationScope, ttlMs?: number): PluginInvocationContext {
|
||||
const invocation: PluginInvocationContext = {
|
||||
id: randomUUID(),
|
||||
scope,
|
||||
};
|
||||
const entry: ActiveInvocation = { scope };
|
||||
if (ttlMs !== undefined) {
|
||||
entry.timer = setTimeout(() => {
|
||||
activeInvocations.delete(invocation.id);
|
||||
}, ttlMs);
|
||||
if (entry.timer.unref) entry.timer.unref();
|
||||
}
|
||||
activeInvocations.set(invocation.id, entry);
|
||||
return invocation;
|
||||
}
|
||||
|
||||
function clearInvocation(invocation: PluginInvocationContext | null): void {
|
||||
if (!invocation) return;
|
||||
const entry = activeInvocations.get(invocation.id);
|
||||
if (entry?.timer) clearTimeout(entry.timer);
|
||||
activeInvocations.delete(invocation.id);
|
||||
}
|
||||
|
||||
function contextForWorkerMessage(message: JsonRpcRequest | JsonRpcNotification): WorkerHostCallContext {
|
||||
const invocationId = readNonEmptyString(
|
||||
(message as { paperclipInvocationId?: unknown }).paperclipInvocationId,
|
||||
);
|
||||
if (!invocationId) {
|
||||
return activeInvocations.size > 0 ? { invalidInvocationScope: true } : {};
|
||||
}
|
||||
const entry = activeInvocations.get(invocationId);
|
||||
if (!entry) return { invalidInvocationScope: true };
|
||||
return { invocationScope: entry.scope };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a JSON-RPC request from the worker (worker→host call).
|
||||
*/
|
||||
async function handleWorkerRequest(request: JsonRpcRequest): Promise<void> {
|
||||
const method = request.method as WorkerToHostMethodName;
|
||||
const handler = options.hostHandlers[method] as
|
||||
| ((params: unknown) => Promise<unknown>)
|
||||
| ((params: unknown, context?: WorkerHostCallContext) => Promise<unknown>)
|
||||
| undefined;
|
||||
|
||||
if (!handler) {
|
||||
|
|
@ -501,7 +577,7 @@ export function createPluginWorkerHandle(
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await handler(request.params);
|
||||
const result = await handler(request.params, contextForWorkerMessage(request));
|
||||
sendMessage({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: request.id,
|
||||
|
|
@ -509,12 +585,15 @@ export function createPluginWorkerHandle(
|
|||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
const errorCode = typeof (err as { code?: unknown }).code === "number"
|
||||
? (err as { code: number }).code
|
||||
: JSONRPC_ERROR_CODES.INTERNAL_ERROR;
|
||||
log.error({ method, err: errorMessage }, "host handler error");
|
||||
try {
|
||||
sendMessage(
|
||||
createErrorResponse(
|
||||
request.id,
|
||||
JSONRPC_ERROR_CODES.INTERNAL_ERROR,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
),
|
||||
);
|
||||
|
|
@ -572,12 +651,28 @@ export function createPluginWorkerHandle(
|
|||
notification.method === "streams.close"
|
||||
) {
|
||||
const params = (notification.params ?? {}) as Record<string, unknown>;
|
||||
const companyId = String(params.companyId ?? "");
|
||||
const context = contextForWorkerMessage(notification);
|
||||
if (context.invalidInvocationScope) {
|
||||
log.warn(
|
||||
{ method: notification.method, companyId },
|
||||
"dropping plugin stream notification with invalid invocation scope",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const allowedCompanyId = context.invocationScope?.companyId;
|
||||
if (allowedCompanyId && companyId !== allowedCompanyId) {
|
||||
log.warn(
|
||||
{ method: notification.method, companyId, allowedCompanyId },
|
||||
"dropping plugin stream notification outside invocation company scope",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Track open channels so we can emit synthetic close on crash
|
||||
if (notification.method === "streams.open") {
|
||||
const ch = String(params.channel ?? "");
|
||||
const co = String(params.companyId ?? "");
|
||||
if (ch) openStreamChannels.set(ch, co);
|
||||
if (ch) openStreamChannels.set(ch, companyId);
|
||||
} else if (notification.method === "streams.close") {
|
||||
openStreamChannels.delete(String(params.channel ?? ""));
|
||||
}
|
||||
|
|
@ -760,6 +855,10 @@ export function createPluginWorkerHandle(
|
|||
);
|
||||
}
|
||||
pendingRequests.clear();
|
||||
for (const invocation of activeInvocations.values()) {
|
||||
if (invocation.timer) clearTimeout(invocation.timer);
|
||||
}
|
||||
activeInvocations.clear();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -1020,6 +1119,8 @@ export function createPluginWorkerHandle(
|
|||
|
||||
const id = nextRequestId++;
|
||||
const timeout = Math.min(timeoutMs ?? rpcTimeoutMs, MAX_RPC_TIMEOUT_MS);
|
||||
const invocationScope = deriveInvocationScope(method, params);
|
||||
const invocation = invocationScope ? registerInvocation(invocationScope) : null;
|
||||
|
||||
// Guard against double-settlement. When a process exits all pending
|
||||
// requests are rejected via rejectAllPending(), but the timeout timer
|
||||
|
|
@ -1032,6 +1133,7 @@ export function createPluginWorkerHandle(
|
|||
settled = true;
|
||||
clearTimeout(timer);
|
||||
pendingRequests.delete(id);
|
||||
clearInvocation(invocation);
|
||||
fn(value);
|
||||
};
|
||||
|
||||
|
|
@ -1064,11 +1166,15 @@ export function createPluginWorkerHandle(
|
|||
pendingRequests.set(id, pending);
|
||||
|
||||
try {
|
||||
const request = createRequest(method, params, id);
|
||||
const request = {
|
||||
...createRequest(method, params, id),
|
||||
...(invocation ? { paperclipInvocation: invocation } : {}),
|
||||
};
|
||||
sendMessage(request);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
pendingRequests.delete(id);
|
||||
clearInvocation(invocation);
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to send "${method}" to worker: ${
|
||||
|
|
@ -1135,13 +1241,17 @@ export function createPluginWorkerHandle(
|
|||
|
||||
notify(method: string, params: unknown) {
|
||||
if (status !== "running") return;
|
||||
const invocationScope = deriveInvocationScope(method, params);
|
||||
const invocation = invocationScope ? registerInvocation(invocationScope, MAX_RPC_TIMEOUT_MS) : null;
|
||||
try {
|
||||
sendMessage({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
method,
|
||||
params,
|
||||
...(invocation ? { paperclipInvocation: invocation } : {}),
|
||||
});
|
||||
} catch {
|
||||
clearInvocation(invocation);
|
||||
log.warn({ method }, "failed to send notification to worker");
|
||||
}
|
||||
},
|
||||
|
|
|
|||
141
server/src/services/principal-access-compatibility.ts
Normal file
141
server/src/services/principal-access-compatibility.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { and, eq, notInArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, companyMemberships, principalPermissionGrants } from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js";
|
||||
|
||||
type GrantInput = {
|
||||
permissionKey: PermissionKey;
|
||||
scope?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type PrincipalAccessCompatibilityBackfillStats = {
|
||||
agentMembershipsInserted: number;
|
||||
humanGrantsInserted: number;
|
||||
};
|
||||
|
||||
export async function insertMissingPrincipalGrants(
|
||||
db: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
principalType: PrincipalType;
|
||||
principalId: string;
|
||||
grants: GrantInput[];
|
||||
grantedByUserId: string | null;
|
||||
},
|
||||
): Promise<number> {
|
||||
if (input.grants.length === 0) return 0;
|
||||
|
||||
const now = new Date();
|
||||
const inserted = await db
|
||||
.insert(principalPermissionGrants)
|
||||
.values(
|
||||
input.grants.map((grant) => ({
|
||||
companyId: input.companyId,
|
||||
principalType: input.principalType,
|
||||
principalId: input.principalId,
|
||||
permissionKey: grant.permissionKey,
|
||||
scope: grant.scope ?? null,
|
||||
grantedByUserId: input.grantedByUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
principalPermissionGrants.companyId,
|
||||
principalPermissionGrants.principalType,
|
||||
principalPermissionGrants.principalId,
|
||||
principalPermissionGrants.permissionKey,
|
||||
],
|
||||
})
|
||||
.returning({ id: principalPermissionGrants.id });
|
||||
|
||||
return inserted.length;
|
||||
}
|
||||
|
||||
export async function ensureHumanRoleDefaultGrants(
|
||||
db: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
principalId: string;
|
||||
membershipRole: string | null | undefined;
|
||||
grantedByUserId: string | null;
|
||||
},
|
||||
): Promise<number> {
|
||||
const role = normalizeHumanRole(input.membershipRole, "operator");
|
||||
return insertMissingPrincipalGrants(db, {
|
||||
companyId: input.companyId,
|
||||
principalType: "user",
|
||||
principalId: input.principalId,
|
||||
grants: grantsForHumanRole(role),
|
||||
grantedByUserId: input.grantedByUserId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function backfillPrincipalAccessCompatibility(
|
||||
db: Db,
|
||||
): Promise<PrincipalAccessCompatibilityBackfillStats> {
|
||||
const now = new Date();
|
||||
const nonTerminalAgents = await db
|
||||
.select({
|
||||
companyId: agents.companyId,
|
||||
principalId: agents.id,
|
||||
})
|
||||
.from(agents)
|
||||
.where(notInArray(agents.status, ["pending_approval", "terminated"]));
|
||||
|
||||
const agentMembershipsInserted = nonTerminalAgents.length > 0
|
||||
? await db
|
||||
.insert(companyMemberships)
|
||||
.values(
|
||||
nonTerminalAgents.map((agent) => ({
|
||||
companyId: agent.companyId,
|
||||
principalType: "agent",
|
||||
principalId: agent.principalId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
companyMemberships.companyId,
|
||||
companyMemberships.principalType,
|
||||
companyMemberships.principalId,
|
||||
],
|
||||
})
|
||||
.returning({ id: companyMemberships.id })
|
||||
.then((rows) => rows.length)
|
||||
: 0;
|
||||
|
||||
const activeHumanMemberships = await db
|
||||
.select({
|
||||
companyId: companyMemberships.companyId,
|
||||
principalId: companyMemberships.principalId,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
);
|
||||
|
||||
let humanGrantsInserted = 0;
|
||||
for (const membership of activeHumanMemberships) {
|
||||
humanGrantsInserted += await ensureHumanRoleDefaultGrants(db, {
|
||||
companyId: membership.companyId,
|
||||
principalId: membership.principalId,
|
||||
membershipRole: membership.membershipRole,
|
||||
grantedByUserId: null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
agentMembershipsInserted,
|
||||
humanGrantsInserted,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue