diff --git a/packages/plugins/paperclip-ee-permissions/.gitignore b/packages/plugins/paperclip-ee-permissions/.gitignore new file mode 100644 index 00000000000..84f3869a736 --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +*.tsbuildinfo diff --git a/packages/plugins/paperclip-ee-permissions/README.md b/packages/plugins/paperclip-ee-permissions/README.md new file mode 100644 index 00000000000..f3bfe5d700f --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/README.md @@ -0,0 +1,67 @@ +# @paperclipai/plugin-paperclip-ee-permissions + +Closed-source Paperclip EE plugin that hosts the advanced company access, +grants, and authorization-policy UX. + +## Status + +Phase 5 implementation surface. The plugin registers a `companySettingsPage` +slot at `/permissions`, activates a per-company EE mode stub, and renders the +advanced authorization controls through typed plugin-SDK access and +authorization clients. + +## Capabilities + +- `access.members.read` / `access.members.write` +- `access.invites.read` / `access.invites.write` +- `authorization.grants.read` / `authorization.grants.write` +- `authorization.policies.read` / `authorization.policies.write` +- `authorization.audit.read` +- `companies.read`, `agents.read`, `issues.read` +- `plugin.state.read`, `plugin.state.write` +- `instance.settings.register`, `ui.page.register` + +The plugin never imports from `ui/src/api/access.ts` or any other host UI +internal — all data flows through `ctx.access.*` and `ctx.authorization.*`. + +## Deterministic missing-state UX + +The page renders one of three states: + +| Condition | UI | +| ------------------------------------------ | ----------------------------------------------------- | +| `companyId === null` | "No active company" card | +| License inactive for company | "Advanced permissions mode is not active" with Activate button | +| License active, host returns capability or worker error | Inline warning card; rest of the page still renders | + +## Advanced surfaces + +When licensed, the company settings page exposes: + +- Agent visibility policy editing (`discoverable` / `private`). +- Assignment policy editing with broad, scoped-to-agent, and clear grant modes. +- Protected-agent approval requirement fields. +- Permission preview and explanation panels that call the same core + authorization decision path used by enforcement. +- Authorization audit filters for actor, resource, action, and decision. + +The license check is a per-company plugin-state stub today; later phases +replace it with a core-owned `advanced_permissions_enabled` flag. + +## Scripts + +- `pnpm build` — bundle worker, manifest, and UI via esbuild +- `pnpm test` — run vitest harness tests for the worker + +## Layout + +``` +src/ + manifest.ts # Slot + capability declarations + worker.ts # getData / performAction handlers + ui/ + index.tsx # Re-exports the slot component + app.tsx # EePermissionsCompanySettingsPage +tests/ + plugin.spec.ts # Worker harness tests +``` diff --git a/packages/plugins/paperclip-ee-permissions/esbuild.config.mjs b/packages/plugins/paperclip-ee-permissions/esbuild.config.mjs new file mode 100644 index 00000000000..b5cfd36ed95 --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/esbuild.config.mjs @@ -0,0 +1,17 @@ +import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); +const uiCtx = await esbuild.context(presets.esbuild.ui); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); + console.log("esbuild watch mode enabled for worker, manifest, and ui"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); +} diff --git a/packages/plugins/paperclip-ee-permissions/package.json b/packages/plugins/paperclip-ee-permissions/package.json new file mode 100644 index 00000000000..29d7b4ff7f2 --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/package.json @@ -0,0 +1,45 @@ +{ + "name": "@paperclipai/plugin-paperclip-ee-permissions", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "Paperclip EE plugin that hosts advanced company access, grants, and authorization-policy UX.", + "scripts": { + "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", + "build": "node ./esbuild.config.mjs", + "build:rollup": "rollup -c", + "dev": "node ./esbuild.config.mjs --watch", + "test": "vitest run --config ./vitest.config.ts", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "keywords": [ + "paperclip", + "plugin", + "ee", + "permissions", + "authorization" + ], + "author": "Paperclip", + "license": "UNLICENSED", + "devDependencies": { + "@paperclipai/plugin-sdk": "workspace:*", + "@paperclipai/shared": "workspace:*", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "esbuild": "^0.27.3", + "rollup": "^4.38.0", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/paperclip-ee-permissions/rollup.config.mjs b/packages/plugins/paperclip-ee-permissions/rollup.config.mjs new file mode 100644 index 00000000000..ccee40a7443 --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/rollup.config.mjs @@ -0,0 +1,28 @@ +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); + +function withPlugins(config) { + if (!config) return null; + return { + ...config, + plugins: [ + nodeResolve({ + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + }), + typescript({ + tsconfig: "./tsconfig.json", + declaration: false, + declarationMap: false, + }), + ], + }; +} + +export default [ + withPlugins(presets.rollup.manifest), + withPlugins(presets.rollup.worker), + withPlugins(presets.rollup.ui), +].filter(Boolean); diff --git a/packages/plugins/paperclip-ee-permissions/src/manifest.ts b/packages/plugins/paperclip-ee-permissions/src/manifest.ts new file mode 100644 index 00000000000..e5ac1898040 --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/src/manifest.ts @@ -0,0 +1,57 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +export const PLUGIN_ID = "paperclipai.plugin-paperclip-ee-permissions"; + +export const SLOT_IDS = { + companySettingsPage: "ee-permissions-company-settings-page", +} as const; + +export const EXPORT_NAMES = { + companySettingsPage: "EePermissionsCompanySettingsPage", +} as const; + +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: "0.1.0", + displayName: "Paperclip EE Permissions", + description: + "Advanced company access, grants, and authorization-policy UX for licensed Paperclip EE deployments.", + author: "Paperclip", + categories: ["ui"], + capabilities: [ + "companies.read", + "agents.read", + "issues.read", + "access.members.read", + "access.invites.read", + "authorization.grants.read", + "authorization.policies.read", + "authorization.audit.read", + "access.members.write", + "access.invites.write", + "authorization.grants.write", + "authorization.policies.write", + "plugin.state.read", + "plugin.state.write", + "instance.settings.register", + "ui.page.register", + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + ui: { + slots: [ + { + type: "companySettingsPage", + id: SLOT_IDS.companySettingsPage, + displayName: "Permissions", + exportName: EXPORT_NAMES.companySettingsPage, + routePath: "permissions", + }, + ], + }, +}; + +export default manifest; diff --git a/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx b/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx new file mode 100644 index 00000000000..90852da649b --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx @@ -0,0 +1,623 @@ +import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import { + useHostContext, + usePluginAction, + usePluginData, + type PluginCompanySettingsPageProps, +} from "@paperclipai/plugin-sdk/ui"; + +type LicenseState = { + status: "active" | "inactive"; + activatedAt?: string; + activatedByUserId?: string | null; + note?: string | null; +}; + +type PolicySummary = { + companyId: string; + permissionsMode: "simple"; + memberCount: number; + activeMemberCount: number; + grantCount: number; + advancedPolicyAvailable: false; +}; + +type AgentRecord = { + id: string; + name: string; + role?: string | null; + status?: string | null; +}; + +type IssueRecord = { + id: string; + title: string; + status: string; + projectId?: string | null; +}; + +type DecisionRecord = { + allowed: boolean; + action: string; + explanation: string; + reason: string; + grant?: { + permissionKey: string; + scope: Record | null; + }; +}; + +type Overview = { + companyId: string; + license: LicenseState; + policySummary: PolicySummary | null; + warnings: Array<{ code: string; message: string }>; +}; + +type AdvancedPolicyData = { + summary: PolicySummary | null; + warnings: Overview["warnings"]; + agents: AgentRecord[]; + issues: IssueRecord[]; + selected: { + actorAgentId: string | null; + targetAgentId: string | null; + projectId: string | null; + issueId: string | null; + }; + agentPolicy: { + resourceType: string; + resourceId: string; + policy: Record | null; + updatedAt: string | null; + } | null; + actorGrants: Array<{ + permissionKey: string; + scope: Record | null; + }>; + preview: DecisionRecord | null; + explanation: DecisionRecord | null; + auditEntries: Array<{ + id: string; + actorType: string; + actorId: string; + action: string; + entityType: string; + entityId: string; + details: Record | null; + createdAt: string; + }>; +}; + +const layoutStack: CSSProperties = { + display: "grid", + gap: "16px", + padding: "24px 0", +}; + +const cardStyle: CSSProperties = { + border: "1px solid rgba(148, 163, 184, 0.28)", + borderRadius: "8px", + padding: "16px 20px", + display: "grid", + gap: "10px", +}; + +const subtleCardStyle: CSSProperties = { + border: "1px solid rgba(148, 163, 184, 0.22)", + borderRadius: "8px", + padding: "14px", + display: "grid", + gap: "10px", +}; + +const mutedTextStyle: CSSProperties = { + color: "rgba(100, 116, 139, 0.95)", + fontSize: "0.9rem", + lineHeight: 1.5, +}; + +const sectionHeadingStyle: CSSProperties = { + fontSize: "0.75rem", + letterSpacing: "0.08em", + textTransform: "uppercase", + color: "rgba(100, 116, 139, 0.95)", +}; + +const rowStyle: CSSProperties = { + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: "8px", +}; + +const gridStyle: CSSProperties = { + display: "grid", + gap: "12px", + gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", +}; + +const inputStyle: CSSProperties = { + width: "100%", + border: "1px solid rgba(148, 163, 184, 0.35)", + borderRadius: "6px", + padding: "8px 10px", + background: "transparent", + color: "inherit", + fontSize: "0.85rem", +}; + +const buttonStyle: CSSProperties = { + padding: "7px 12px", + borderRadius: "6px", + border: "1px solid rgba(148, 163, 184, 0.4)", + background: "transparent", + color: "inherit", + cursor: "pointer", +}; + +const primaryButtonStyle: CSSProperties = { + ...buttonStyle, + background: "currentColor", + color: "Canvas", +}; + +const codeStyle: CSSProperties = { + margin: 0, + maxHeight: "240px", + overflow: "auto", + border: "1px solid rgba(148, 163, 184, 0.18)", + borderRadius: "6px", + padding: "10px", + fontSize: "0.75rem", + lineHeight: 1.45, +}; + +const warningStyle: CSSProperties = { + border: "1px solid rgba(234, 179, 8, 0.4)", + background: "rgba(234, 179, 8, 0.08)", + borderRadius: "8px", + padding: "10px 12px", + color: "rgba(120, 88, 0, 0.95)", +}; + +function JsonBlock({ value }: { value: unknown }) { + return
{JSON.stringify(value, null, 2)}
; +} + +function Pill({ label, tone = "neutral" }: { label: string; tone?: "neutral" | "allow" | "deny" }) { + const colors = tone === "allow" + ? { border: "rgba(22, 163, 74, 0.5)", background: "rgba(22, 163, 74, 0.1)" } + : tone === "deny" + ? { border: "rgba(220, 38, 38, 0.5)", background: "rgba(220, 38, 38, 0.08)" } + : { border: "rgba(148, 163, 184, 0.35)", background: "transparent" }; + return ( + + {label} + + ); +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function CapabilityWarning({ warnings }: { warnings: Overview["warnings"] }) { + if (warnings.length === 0) return null; + return ( +
+ Some advanced data could not be loaded. +
    + {warnings.map((warning, index) => ( +
  • + {warning.code}: {warning.message} +
  • + ))} +
+
+ ); +} + +function MissingCompanyState() { + return ( +
+
+
Permissions
+ No active company +
Switch into a company to manage advanced permissions.
+
+
+ ); +} + +function UnlicensedState({ + companyId, + onActivate, + activating, +}: { + companyId: string; + onActivate: () => void; + activating: boolean; +}) { + return ( +
+
+
Paperclip EE Permissions
+ Advanced permissions mode is not active +
+ Members can collaborate across this company by default. Activate Paperclip EE permissions to unlock scoped grants, protected-agent controls, assignment previews, and audit filters. +
+
+ +
+
+ Company: {companyId} +
+
+
+ ); +} + +function getPolicySection(policy: Record | null | undefined, key: string): Record { + const value = policy?.[key]; + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function getPolicyString(policy: Record | null | undefined, section: string, key: string, fallback: string) { + const value = getPolicySection(policy, section)[key]; + return typeof value === "string" ? value : fallback; +} + +function getPolicyBoolean(policy: Record | null | undefined, section: string, key: string, fallback: boolean) { + const value = getPolicySection(policy, section)[key]; + return typeof value === "boolean" ? value : fallback; +} + +function AdvancedPolicyEditor({ companyId }: { companyId: string }) { + const saveAgentPolicy = usePluginAction("saveAgentPolicy"); + const saveAssignmentGrant = usePluginAction("saveAssignmentGrant"); + const [actorAgentId, setActorAgentId] = useState(""); + const [targetAgentId, setTargetAgentId] = useState(""); + const [projectId, setProjectId] = useState(""); + const [issueId, setIssueId] = useState(""); + const [auditAction, setAuditAction] = useState(""); + const [auditActorType, setAuditActorType] = useState(""); + const [auditEntityType, setAuditEntityType] = useState(""); + const [auditEntityId, setAuditEntityId] = useState(""); + const [auditDecision, setAuditDecision] = useState(""); + const [visibilityMode, setVisibilityMode] = useState("discoverable"); + const [assignmentMode, setAssignmentMode] = useState("company_default"); + const [requiresApproval, setRequiresApproval] = useState(false); + const [approvalReason, setApprovalReason] = useState(""); + const [grantMode, setGrantMode] = useState("scoped_agent"); + const [busyAction, setBusyAction] = useState(null); + const [lastResult, setLastResult] = useState(null); + const params = useMemo(() => ({ + companyId, + actorAgentId, + targetAgentId, + projectId, + issueId, + auditAction, + auditActorType, + auditEntityType, + auditEntityId, + auditDecision, + }), [companyId, actorAgentId, targetAgentId, projectId, issueId, auditAction, auditActorType, auditEntityType, auditEntityId, auditDecision]); + const query = usePluginData("advancedPolicy", params); + const data = query.data; + + useEffect(() => { + if (!data) return; + if (!actorAgentId && data.selected.actorAgentId) setActorAgentId(data.selected.actorAgentId); + if (!targetAgentId && data.selected.targetAgentId) setTargetAgentId(data.selected.targetAgentId); + }, [actorAgentId, data, targetAgentId]); + + useEffect(() => { + const policy = data?.agentPolicy?.policy; + setVisibilityMode(getPolicyString(policy, "agentVisibility", "mode", "discoverable")); + setAssignmentMode(getPolicyString(policy, "assignmentPolicy", "mode", "company_default")); + setRequiresApproval(getPolicyBoolean(policy, "protectedAgent", "requiresApproval", false)); + setApprovalReason(getPolicyString(policy, "protectedAgent", "approvalReason", "")); + }, [data?.agentPolicy?.resourceId, data?.agentPolicy?.updatedAt]); + + const projectOptions = useMemo(() => { + const ids = new Set(); + for (const issue of data?.issues ?? []) { + if (issue.projectId) ids.add(issue.projectId); + } + return [...ids]; + }, [data?.issues]); + + async function run(label: string, action: () => Promise) { + setBusyAction(label); + try { + const result = await action(); + setLastResult(result); + query.refresh(); + } catch (error) { + setLastResult({ error: getErrorMessage(error) }); + } finally { + setBusyAction(null); + } + } + + if (query.loading && !data) { + return
Loading advanced policy editors...
; + } + + if (query.error) { + return ( +
+ Advanced policy APIs unavailable. +
{query.error.message}
+
+ ); + } + + return ( +
+ + +
+
+
+ Mode + +
+
+ Active members {data?.summary?.activeMemberCount ?? 0} / {data?.summary?.memberCount ?? 0}. Explicit grants {data?.summary?.grantCount ?? 0}. +
+
+
+
+ Preview Decision + +
+
{data?.preview?.explanation ?? "Select an actor and target agent."}
+
+
+ +
+ + + + +
+ +
+
+
Agent Visibility
+ + + + setApprovalReason(event.target.value)} /> + +
+ +
+
Assignment Policy
+ + + +
+
+ +
+
+
Permission Explanation
+
+ + {data?.explanation?.explanation ?? "No explanation returned."} +
+ +
+
+
Current Agent Policy
+ +
+
+ +
+
+
Authorization Audit
+ +
+
+ setAuditAction(event.target.value)} /> + + setAuditEntityType(event.target.value)} /> + setAuditEntityId(event.target.value)} /> + +
+ +
+ + {lastResult ? : null} +
+ ); +} + +export function EePermissionsCompanySettingsPage(_props: PluginCompanySettingsPageProps) { + const hostContext = useHostContext(); + const companyId = hostContext.companyId; + const overview = usePluginData("overview", companyId ? { companyId } : {}); + const activate = usePluginAction("activateLicense"); + const deactivate = usePluginAction("deactivateLicense"); + const [activationBusy, setActivationBusy] = useState(false); + + if (!companyId) return ; + + if (overview.loading && !overview.data) { + return ( +
+
+
Loading permissions overview...
+
+
+ ); + } + + if (overview.error) { + return ( +
+
+
Permissions
+ Could not load permissions +
+ {overview.error.code}: {overview.error.message} +
+
+
+ ); + } + + const data = overview.data; + if (!data) { + return ( +
+
+
No data returned.
+
+
+ ); + } + + if (data.license.status !== "active") { + return ( + { + setActivationBusy(true); + void activate({ companyId }) + .then(() => overview.refresh()) + .finally(() => setActivationBusy(false)); + }} + /> + ); + } + + return ( +
+
+
+
Paperclip EE Permissions
+ +
+ Advanced policy editing is active +
+ Policy data stays in core and this plugin edits it through capability-gated SDK calls. If the plugin is unavailable later, existing restrictions remain server-enforced. +
+
+ +
+
+ + +
+ ); +} diff --git a/packages/plugins/paperclip-ee-permissions/src/ui/index.tsx b/packages/plugins/paperclip-ee-permissions/src/ui/index.tsx new file mode 100644 index 00000000000..3860a02129e --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/src/ui/index.tsx @@ -0,0 +1 @@ +export { EePermissionsCompanySettingsPage } from "./app.js"; diff --git a/packages/plugins/paperclip-ee-permissions/src/worker.ts b/packages/plugins/paperclip-ee-permissions/src/worker.ts new file mode 100644 index 00000000000..2a1d8e5965f --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/src/worker.ts @@ -0,0 +1,451 @@ +import { + definePlugin, + runWorker, + PLUGIN_RPC_ERROR_CODES, + type PluginAccessMember, + type PluginAuthorizationAuditEntry, + type PluginAuthorizationPolicySummary, + type PluginContext, + type Issue, + type PermissionKey, + type PrincipalPermissionGrant, +} from "@paperclipai/plugin-sdk"; + +const LICENSE_STATE_SCOPE = "company"; +const LICENSE_STATE_KEY = "license"; + +/** + * Worker-visible license state. Phase 3A scaffolds a deterministic stub: + * the plugin defaults to "unlicensed" unless an operator has explicitly + * activated the EE permissions mode for the company. Later phases replace + * this with a real license check tied to a core-owned advanced-mode flag. + */ +export type EePermissionsLicense = { + status: "active" | "inactive"; + activatedAt?: string; + activatedByUserId?: string | null; + note?: string | null; +}; + +const DEFAULT_LICENSE: EePermissionsLicense = { status: "inactive" }; + +/** + * Shape returned by `getData("overview", { companyId })`. The UI uses this + * as the single source of truth for whether to render the advanced controls + * or one of the deterministic missing-state fallbacks. + */ +export type EePermissionsOverview = { + companyId: string; + license: EePermissionsLicense; + policySummary: PluginAuthorizationPolicySummary | null; + warnings: Array<{ code: string; message: string }>; +}; + +type AssignmentGrantMode = "broad" | "scoped_agent" | "clear"; + +export type EePermissionsAdvancedPolicyData = { + companyId: string; + summary: PluginAuthorizationPolicySummary | null; + warnings: Array<{ code: string; message: string }>; + agents: Awaited>; + issues: Issue[]; + selected: { + actorAgentId: string | null; + targetAgentId: string | null; + projectId: string | null; + issueId: string | null; + }; + agentPolicy: Awaited>; + actorGrants: PrincipalPermissionGrant[]; + preview: Awaited> | null; + explanation: Awaited> | null; + auditEntries: PluginAuthorizationAuditEntry[]; +}; + +function readCompanyId(params: Record): string { + const value = params.companyId; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("companyId is required"); + } + return value; +} + +function readNumber(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function readStringOrUndefined(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readString(params: Record, key: string): string { + const value = params[key]; + return typeof value === "string" ? value : ""; +} + +function readBoolean(params: Record, key: string, fallback = false): boolean { + const value = params[key]; + return typeof value === "boolean" ? value : fallback; +} + +function readGrantMode(value: unknown): AssignmentGrantMode { + if (value === "broad" || value === "scoped_agent" || value === "clear") return value; + return "scoped_agent"; +} + +function withoutAssignmentGrants(grants: PrincipalPermissionGrant[]) { + return grants + .filter((grant) => grant.permissionKey !== "tasks:assign" && grant.permissionKey !== "tasks:assign_scope") + .map((grant) => ({ + permissionKey: grant.permissionKey as PermissionKey, + scope: grant.scope && typeof grant.scope === "object" ? grant.scope as Record : null, + })); +} + +function isCapabilityDenied(error: unknown): boolean { + if (!error) return false; + const code = (error as { code?: unknown }).code; + if (code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED) return true; + if (code === "CAPABILITY_DENIED") return true; + const message = error instanceof Error ? error.message : String(error); + return /missing required capability/i.test(message); +} + +function formatError(error: unknown): { code: string; message: string } { + if (isCapabilityDenied(error)) { + return { + code: "CAPABILITY_DENIED", + message: error instanceof Error ? error.message : String(error), + }; + } + return { + code: "WORKER_ERROR", + message: error instanceof Error ? error.message : String(error), + }; +} + +async function readLicense( + ctx: PluginContext, + companyId: string, +): Promise { + const value = (await ctx.state.get({ + scopeKind: LICENSE_STATE_SCOPE, + scopeId: companyId, + stateKey: LICENSE_STATE_KEY, + })) as EePermissionsLicense | null; + if (!value || typeof value !== "object") return DEFAULT_LICENSE; + return value.status === "active" ? value : DEFAULT_LICENSE; +} + +const plugin = definePlugin({ + async setup(ctx) { + ctx.data.register("overview", async (params) => { + const companyId = readCompanyId(params ?? {}); + const license = await readLicense(ctx, companyId); + const warnings: EePermissionsOverview["warnings"] = []; + if (license.status !== "active") { + return { + companyId, + license, + policySummary: null, + warnings, + } satisfies EePermissionsOverview; + } + let policySummary: PluginAuthorizationPolicySummary | null = null; + try { + policySummary = await ctx.authorization.policies.summary(companyId); + } catch (error) { + warnings.push(formatError(error)); + } + return { + companyId, + license, + policySummary, + warnings, + } satisfies EePermissionsOverview; + }); + + ctx.data.register("members", async (params) => { + const companyId = readCompanyId(params ?? {}); + const license = await readLicense(ctx, companyId); + if (license.status !== "active") return [] as PluginAccessMember[]; + try { + return await ctx.access.members.list({ companyId }); + } catch (error) { + if (isCapabilityDenied(error)) return [] as PluginAccessMember[]; + throw error; + } + }); + + ctx.data.register("grants", async (params) => { + const input = params ?? {}; + const companyId = readCompanyId(input); + const license = await readLicense(ctx, companyId); + if (license.status !== "active") return [] as PrincipalPermissionGrant[]; + try { + return await ctx.authorization.grants.list({ + companyId, + principalType: readStringOrUndefined(input.principalType) as + | PrincipalPermissionGrant["principalType"] + | undefined, + principalId: readStringOrUndefined(input.principalId), + }); + } catch (error) { + if (isCapabilityDenied(error)) return [] as PrincipalPermissionGrant[]; + throw error; + } + }); + + ctx.data.register("audit", async (params) => { + const input = params ?? {}; + const companyId = readCompanyId(input); + const license = await readLicense(ctx, companyId); + if (license.status !== "active") return [] as PluginAuthorizationAuditEntry[]; + try { + return await ctx.authorization.audit.search({ + companyId, + action: readStringOrUndefined(input.action), + actorType: readStringOrUndefined(input.actorType), + actorId: readStringOrUndefined(input.actorId), + entityType: readStringOrUndefined(input.entityType), + entityId: readStringOrUndefined(input.entityId), + decision: readStringOrUndefined(input.decision), + limit: readNumber(input.limit, 25), + offset: readNumber(input.offset, 0), + }); + } catch (error) { + if (isCapabilityDenied(error)) return [] as PluginAuthorizationAuditEntry[]; + throw error; + } + }); + + ctx.data.register("advancedPolicy", async (params) => { + const input = params ?? {}; + const companyId = readCompanyId(input); + const license = await readLicense(ctx, companyId); + if (license.status !== "active") { + return { + companyId, + summary: null, + warnings: [], + agents: [], + issues: [], + selected: { + actorAgentId: null, + targetAgentId: null, + projectId: null, + issueId: null, + }, + agentPolicy: null, + actorGrants: [], + preview: null, + explanation: null, + auditEntries: [], + } satisfies EePermissionsAdvancedPolicyData; + } + + const warnings: EePermissionsAdvancedPolicyData["warnings"] = []; + const [summary, agents, issues] = await Promise.all([ + ctx.authorization.policies.summary(companyId).catch((error) => { + warnings.push(formatError(error)); + return null; + }), + ctx.agents.list({ companyId, limit: 200, offset: 0 }).catch((error) => { + warnings.push(formatError(error)); + return []; + }), + ctx.issues.list({ companyId, limit: 100, offset: 0 }).catch((error) => { + warnings.push(formatError(error)); + return [] as Issue[]; + }), + ]); + const actorAgent = agents.find((agent) => agent.id === readString(input, "actorAgentId")) ?? agents[0] ?? null; + const targetAgent = agents.find((agent) => agent.id === readString(input, "targetAgentId")) + ?? agents.find((agent) => agent.id !== actorAgent?.id) + ?? agents[0] + ?? null; + const selectedProjectId = readString(input, "projectId") || null; + const selectedIssueId = readString(input, "issueId") || null; + const [agentPolicy, actorGrants, preview, explanation, auditEntries] = await Promise.all([ + targetAgent + ? ctx.authorization.policies.get({ companyId, resourceType: "agent", resourceId: targetAgent.id }).catch((error) => { + warnings.push(formatError(error)); + return null; + }) + : Promise.resolve(null), + actorAgent + ? ctx.authorization.grants.list({ companyId, principalType: "agent", principalId: actorAgent.id }).catch((error) => { + warnings.push(formatError(error)); + return [] as PrincipalPermissionGrant[]; + }) + : Promise.resolve([] as PrincipalPermissionGrant[]), + actorAgent && targetAgent + ? ctx.authorization.policies.previewAssignment({ + companyId, + actor: { type: "agent", agentId: actorAgent.id, companyId }, + target: { + issueId: selectedIssueId, + projectId: selectedProjectId, + assigneeAgentId: targetAgent.id, + }, + }).catch((error) => { + warnings.push(formatError(error)); + return null; + }) + : Promise.resolve(null), + actorAgent && targetAgent + ? ctx.authorization.policies.explainAssignment({ + companyId, + actor: { type: "agent", agentId: actorAgent.id, companyId }, + target: { + issueId: selectedIssueId, + projectId: selectedProjectId, + assigneeAgentId: targetAgent.id, + }, + }).catch((error) => { + warnings.push(formatError(error)); + return null; + }) + : Promise.resolve(null), + ctx.authorization.audit.search({ + companyId, + action: readStringOrUndefined(input.action), + actorType: readStringOrUndefined(input.actorType), + actorId: readStringOrUndefined(input.actorId), + entityType: readStringOrUndefined(input.entityType), + entityId: readStringOrUndefined(input.entityId), + decision: readStringOrUndefined(input.decision), + limit: 25, + offset: 0, + }).catch((error) => { + warnings.push(formatError(error)); + return [] as PluginAuthorizationAuditEntry[]; + }), + ]); + + return { + companyId, + summary, + warnings, + agents, + issues, + selected: { + actorAgentId: actorAgent?.id ?? null, + targetAgentId: targetAgent?.id ?? null, + projectId: selectedProjectId, + issueId: selectedIssueId, + }, + agentPolicy, + actorGrants, + preview, + explanation, + auditEntries, + } satisfies EePermissionsAdvancedPolicyData; + }); + + ctx.actions.register("activateLicense", async (params) => { + const input = params ?? {}; + const companyId = readCompanyId(input); + const license: EePermissionsLicense = { + status: "active", + activatedAt: new Date().toISOString(), + activatedByUserId: readStringOrUndefined(input.activatedByUserId) ?? null, + note: readStringOrUndefined(input.note) ?? null, + }; + await ctx.state.set( + { + scopeKind: LICENSE_STATE_SCOPE, + scopeId: companyId, + stateKey: LICENSE_STATE_KEY, + }, + license, + ); + return license; + }); + + ctx.actions.register("deactivateLicense", async (params) => { + const companyId = readCompanyId(params ?? {}); + await ctx.state.delete({ + scopeKind: LICENSE_STATE_SCOPE, + scopeId: companyId, + stateKey: LICENSE_STATE_KEY, + }); + return DEFAULT_LICENSE; + }); + + ctx.actions.register("saveAgentPolicy", async (params) => { + const input = params ?? {}; + const companyId = readCompanyId(input); + const license = await readLicense(ctx, companyId); + if (license.status !== "active") throw new Error("Paperclip EE permissions mode is not active for this company"); + const agentId = readString(input, "agentId"); + if (!agentId) throw new Error("agentId is required"); + const visibilityMode = readString(input, "visibilityMode") === "private" ? "private" : "discoverable"; + const assignmentMode = readString(input, "assignmentMode") === "protected" ? "protected" : "company_default"; + const approvalReason = readString(input, "approvalReason").trim(); + return await ctx.authorization.policies.update({ + companyId, + resourceType: "agent", + resourceId: agentId, + policy: { + agentVisibility: { + mode: visibilityMode, + hiddenFromDefaultDirectory: visibilityMode === "private", + }, + assignmentPolicy: { + mode: assignmentMode, + protectedAgentRequiresApproval: assignmentMode === "protected", + }, + protectedAgent: { + requiresApproval: readBoolean(input, "requiresApproval", assignmentMode === "protected"), + approvalReason: approvalReason || null, + }, + managedBy: "paperclip-ee-permissions", + }, + }); + }); + + ctx.actions.register("saveAssignmentGrant", async (params) => { + const input = params ?? {}; + const companyId = readCompanyId(input); + const license = await readLicense(ctx, companyId); + if (license.status !== "active") throw new Error("Paperclip EE permissions mode is not active for this company"); + const actorAgentId = readString(input, "actorAgentId"); + const targetAgentId = readString(input, "targetAgentId"); + const projectId = readString(input, "projectId"); + const mode = readGrantMode(input.mode); + if (!actorAgentId) throw new Error("actorAgentId is required"); + const existing = await ctx.authorization.grants.list({ + companyId, + principalType: "agent", + principalId: actorAgentId, + }); + const grants = withoutAssignmentGrants(existing); + if (mode === "broad") { + grants.push({ permissionKey: "tasks:assign", scope: null }); + } else if (mode === "scoped_agent") { + if (!targetAgentId) throw new Error("targetAgentId is required for scoped assignment grants"); + grants.push({ + permissionKey: "tasks:assign_scope", + scope: { + assigneeAgentId: targetAgentId, + ...(projectId ? { projectId } : {}), + }, + }); + } + return await ctx.authorization.grants.set({ + companyId, + principalType: "agent", + principalId: actorAgentId, + grants, + }); + }); + }, + + async onHealth() { + return { status: "ok", message: "Paperclip EE Permissions worker is running" }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts b/packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts new file mode 100644 index 00000000000..5a76af7305c --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import manifest, { EXPORT_NAMES, SLOT_IDS } from "../src/manifest.js"; +import plugin, { + type EePermissionsAdvancedPolicyData, + type EePermissionsLicense, + type EePermissionsOverview, +} from "../src/worker.js"; + +const COMPANY_ID = "00000000-0000-4000-8000-000000000001"; +const ACTOR_AGENT_ID = "00000000-0000-4000-8000-0000000000a1"; +const TARGET_AGENT_ID = "00000000-0000-4000-8000-0000000000a2"; +const ISSUE_ID = "00000000-0000-4000-8000-0000000000b1"; + +describe("paperclip-ee-permissions manifest", () => { + it("declares the company settings page slot", () => { + const slot = manifest.ui?.slots?.find( + (entry) => entry.id === SLOT_IDS.companySettingsPage, + ); + expect(slot).toBeDefined(); + expect(slot?.type).toBe("companySettingsPage"); + expect(slot?.exportName).toBe(EXPORT_NAMES.companySettingsPage); + expect(slot?.routePath).toBe("permissions"); + }); + + it("requires the access and authorization read capabilities", () => { + for (const capability of [ + "access.members.read", + "access.members.write", + "issues.read", + "authorization.grants.read", + "authorization.grants.write", + "authorization.policies.read", + "authorization.policies.write", + "authorization.audit.read", + "ui.page.register", + "instance.settings.register", + "plugin.state.read", + "plugin.state.write", + ] as const) { + expect(manifest.capabilities).toContain(capability); + } + }); +}); + +describe("paperclip-ee-permissions worker", () => { + it("returns an unlicensed overview by default", async () => { + const harness = createTestHarness({ + manifest, + capabilities: manifest.capabilities, + }); + await plugin.definition.setup(harness.ctx); + const overview = await harness.getData("overview", { + companyId: COMPANY_ID, + }); + expect(overview.companyId).toBe(COMPANY_ID); + expect(overview.license.status).toBe("inactive"); + expect(overview.policySummary).toBeNull(); + expect(overview.warnings).toEqual([]); + }); + + it("activates and deactivates the per-company license", async () => { + const harness = createTestHarness({ + manifest, + capabilities: manifest.capabilities, + }); + await plugin.definition.setup(harness.ctx); + + const activated = await harness.performAction( + "activateLicense", + { companyId: COMPANY_ID, activatedByUserId: "user-1" }, + ); + expect(activated.status).toBe("active"); + expect(activated.activatedByUserId).toBe("user-1"); + + const overview = await harness.getData("overview", { + companyId: COMPANY_ID, + }); + expect(overview.license.status).toBe("active"); + expect(overview.policySummary).toMatchObject({ + companyId: COMPANY_ID, + permissionsMode: "simple", + advancedPolicyAvailable: false, + }); + + const deactivated = await harness.performAction( + "deactivateLicense", + { companyId: COMPANY_ID }, + ); + expect(deactivated.status).toBe("inactive"); + + const reverted = await harness.getData("overview", { + companyId: COMPANY_ID, + }); + expect(reverted.license.status).toBe("inactive"); + expect(reverted.policySummary).toBeNull(); + }); + + it("returns empty lists for grants/members when unlicensed", async () => { + const harness = createTestHarness({ + manifest, + capabilities: manifest.capabilities, + }); + await plugin.definition.setup(harness.ctx); + const members = await harness.getData("members", { companyId: COMPANY_ID }); + const grants = await harness.getData("grants", { companyId: COMPANY_ID }); + const audit = await harness.getData("audit", { companyId: COMPANY_ID }); + expect(members).toEqual([]); + expect(grants).toEqual([]); + expect(audit).toEqual([]); + }); + + it("falls back to empty data when a capability is missing", async () => { + const harness = createTestHarness({ + manifest, + capabilities: manifest.capabilities.filter( + (capability) => capability !== "authorization.grants.read", + ), + }); + await plugin.definition.setup(harness.ctx); + await harness.performAction("activateLicense", { companyId: COMPANY_ID }); + const grants = await harness.getData("grants", { companyId: COMPANY_ID }); + expect(grants).toEqual([]); + }); + + it("loads advanced policy data and previews through the authorization SDK", async () => { + const harness = createTestHarness({ + manifest, + capabilities: manifest.capabilities, + }); + harness.seed({ + agents: [ + { + id: ACTOR_AGENT_ID, + companyId: COMPANY_ID, + name: "Actor", + role: "engineer", + status: "active", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + } as any, + { + id: TARGET_AGENT_ID, + companyId: COMPANY_ID, + name: "Target", + role: "engineer", + status: "active", + adapterType: "process", + adapterConfig: {}, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + } as any, + ], + issues: [ + { + id: ISSUE_ID, + companyId: COMPANY_ID, + title: "Preview issue", + status: "todo", + createdAt: new Date(), + updatedAt: new Date(), + } as any, + ], + }); + await plugin.definition.setup(harness.ctx); + await harness.performAction("activateLicense", { companyId: COMPANY_ID }); + + const data = await harness.getData("advancedPolicy", { + companyId: COMPANY_ID, + actorAgentId: ACTOR_AGENT_ID, + targetAgentId: TARGET_AGENT_ID, + issueId: ISSUE_ID, + decision: "allow", + }); + + expect(data.agents).toHaveLength(2); + expect(data.issues).toHaveLength(1); + expect(data.selected).toMatchObject({ + actorAgentId: ACTOR_AGENT_ID, + targetAgentId: TARGET_AGENT_ID, + issueId: ISSUE_ID, + }); + expect(data.preview).toMatchObject({ allowed: true }); + expect(data.explanation).toMatchObject({ allowed: true }); + }); + + it("updates agent policies and assignment grants through SDK actions", async () => { + const harness = createTestHarness({ + manifest, + capabilities: manifest.capabilities, + }); + await plugin.definition.setup(harness.ctx); + await harness.performAction("activateLicense", { companyId: COMPANY_ID }); + + const policy = await harness.performAction>("saveAgentPolicy", { + companyId: COMPANY_ID, + agentId: TARGET_AGENT_ID, + visibilityMode: "private", + assignmentMode: "protected", + requiresApproval: true, + approvalReason: "Sensitive production agent", + }); + const grants = await harness.performAction("saveAssignmentGrant", { + companyId: COMPANY_ID, + actorAgentId: ACTOR_AGENT_ID, + targetAgentId: TARGET_AGENT_ID, + mode: "scoped_agent", + }); + + expect(policy.policy).toMatchObject({ + agentVisibility: { mode: "private" }, + protectedAgent: { requiresApproval: true }, + }); + expect(grants).toEqual([]); + }); + + it("rejects requests that omit companyId", async () => { + const harness = createTestHarness({ + manifest, + capabilities: manifest.capabilities, + }); + await plugin.definition.setup(harness.ctx); + await expect(harness.getData("overview", {})).rejects.toThrow(/companyId/); + }); +}); diff --git a/packages/plugins/paperclip-ee-permissions/tsconfig.json b/packages/plugins/paperclip-ee-permissions/tsconfig.json new file mode 100644 index 00000000000..a697519ed9d --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022", + "DOM" + ], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "." + }, + "include": [ + "src", + "tests" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/plugins/paperclip-ee-permissions/vitest.config.ts b/packages/plugins/paperclip-ee-permissions/vitest.config.ts new file mode 100644 index 00000000000..649a293ef95 --- /dev/null +++ b/packages/plugins/paperclip-ee-permissions/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +}); diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index c43e2b8323e..dad9364b305 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -218,6 +218,17 @@ export type { PluginIssueSubtree, PluginIssueSummariesClient, PluginAgentsClient, + PluginAccessClient, + PluginAccessMembersClient, + PluginAccessInvitesClient, + PluginAccessMember, + PluginAccessInvite, + PluginAuthorizationClient, + PluginAuthorizationPolicySummary, + PluginAuthorizationPolicyRecord, + PluginAssignmentPreviewInput, + PluginAuthorizationDecisionResult, + PluginAuthorizationAuditEntry, PluginAgentSessionsClient, AgentSession, AgentSessionEvent, @@ -253,6 +264,9 @@ export type { IssueDocumentSummary, Agent, Goal, + PermissionKey, + PrincipalPermissionGrant, + PrincipalType, PluginDatabaseClient, } from "./types.js"; diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index f129aa76b6a..99c47a00376 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -1229,6 +1229,7 @@ export interface WorkerToHostMethods { actorId?: string; entityType?: string; entityId?: string; + decision?: string; limit?: number; offset?: number; }, diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 248b4a8002e..456f4a9e7c0 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -1744,6 +1744,7 @@ export interface PluginAuthorizationClient { actorId?: string; entityType?: string; entityId?: string; + decision?: string; limit?: number; offset?: number; }): Promise; diff --git a/server/src/__tests__/plugin-access-authorization-host-services.test.ts b/server/src/__tests__/plugin-access-authorization-host-services.test.ts index 8b42f1bccec..aa6ea950a7f 100644 --- a/server/src/__tests__/plugin-access-authorization-host-services.test.ts +++ b/server/src/__tests__/plugin-access-authorization-host-services.test.ts @@ -120,4 +120,47 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => expect("tokenHash" in listed.invites[0]!).toBe(false); services.dispose(); }); + + it("filters authorization audit entries by allow or deny decision details", async () => { + const company = await createCompany(db, "PAU"); + const services = buildHostServices(db, "plugin-record-id", "paperclip-ee", createEventBusStub()); + await db.insert(activityLog).values([ + { + companyId: company.id, + actorType: "agent", + actorId: "agent-1", + action: "authorization.assignment_preview", + entityType: "issue", + entityId: "issue-1", + details: { decision: "allow", secret: "do-not-leak" }, + }, + { + companyId: company.id, + actorType: "agent", + actorId: "agent-1", + action: "authorization.assignment_preview", + entityType: "issue", + entityId: "issue-2", + details: { reason: "deny_scope" }, + }, + ]); + + const allowed = await services.authorization.searchAudit({ + companyId: company.id, + action: "authorization.assignment_preview", + decision: "allow", + }); + const denied = await services.authorization.searchAudit({ + companyId: company.id, + action: "authorization.assignment_preview", + decision: "deny", + }); + + expect(allowed).toHaveLength(1); + expect(allowed[0]!.entityId).toBe("issue-1"); + expect(allowed[0]!.details).toMatchObject({ decision: "allow", secret: "***REDACTED***" }); + expect(denied).toHaveLength(1); + expect(denied[0]!.entityId).toBe("issue-2"); + services.dispose(); + }); }); diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index a69706ccbdb..9f3142d9cfd 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -2483,7 +2483,27 @@ export function buildHostServices( .orderBy(desc(activityLog.createdAt)) .limit(limit) .offset(offset); - return rows.map((row) => ({ + const decisionFilter = typeof params.decision === "string" && params.decision.trim() + ? params.decision.trim().toLowerCase() + : null; + const filteredRows = decisionFilter + ? rows.filter((row) => { + const details = row.details && typeof row.details === "object" + ? row.details as Record + : {}; + const decision = typeof details.decision === "string" + ? details.decision + : typeof details.reason === "string" && details.reason.startsWith("allow_") + ? "allow" + : typeof details.reason === "string" && details.reason.startsWith("deny_") + ? "deny" + : typeof details.allowed === "boolean" + ? details.allowed ? "allow" : "deny" + : ""; + return decision.toLowerCase() === decisionFilter; + }) + : rows; + return filteredRows.map((row) => ({ ...row, details: row.details && typeof row.details === "object" ? sanitizeRecord(row.details)