Add EE permissions policy editor surfaces

This commit is contained in:
Dotta committed 2026-05-22 01:51:03 +00:00
1 parent 980af5e3ab
commit 022616389c
17 files changed
+1636 -1

No files matched your search

@@ -0,0 +1,3 @@
dist
node_modules
*.tsbuildinfo
@@ -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
```
@@ -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()]);
}
@@ -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"
}
}
@@ -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);
@@ -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;
@@ -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<string, unknown> | 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<string, unknown> | null;
updatedAt: string | null;
} | null;
actorGrants: Array<{
permissionKey: string;
scope: Record<string, unknown> | null;
}>;
preview: DecisionRecord | null;
explanation: DecisionRecord | null;
auditEntries: Array<{
id: string;
actorType: string;
actorId: string;
action: string;
entityType: string;
entityId: string;
details: Record<string, unknown> | 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 <pre style={codeStyle}>{JSON.stringify(value, null, 2)}</pre>;
}
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 (
<span
style={{
border: `1px solid ${colors.border}`,
background: colors.background,
borderRadius: "999px",
padding: "2px 8px",
fontSize: "0.72rem",
}}
>
{label}
</span>
);
}
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 (
<div style={warningStyle}>
<strong>Some advanced data could not be loaded.</strong>
<ul style={{ margin: "6px 0 0", paddingLeft: "18px" }}>
{warnings.map((warning, index) => (
<li key={`${warning.code}-${index}`}>
<code>{warning.code}</code>: {warning.message}
</li>
))}
</ul>
</div>
);
}
function MissingCompanyState() {
return (
<div style={layoutStack}>
<div style={cardStyle}>
<div style={sectionHeadingStyle}>Permissions</div>
<strong>No active company</strong>
<div style={mutedTextStyle}>Switch into a company to manage advanced permissions.</div>
</div>
</div>
);
}
function UnlicensedState({
companyId,
onActivate,
activating,
}: {
companyId: string;
onActivate: () => void;
activating: boolean;
}) {
return (
<div style={layoutStack}>
<div style={cardStyle}>
<div style={sectionHeadingStyle}>Paperclip EE Permissions</div>
<strong>Advanced permissions mode is not active</strong>
<div style={mutedTextStyle}>
Members can collaborate across this company by default. Activate Paperclip EE permissions to unlock scoped grants, protected-agent controls, assignment previews, and audit filters.
</div>
<div>
<button type="button" style={buttonStyle} disabled={activating} onClick={onActivate}>
{activating ? "Activating..." : "Activate for this company"}
</button>
</div>
<div style={mutedTextStyle}>
Company: <code>{companyId}</code>
</div>
</div>
</div>
);
}
function getPolicySection(policy: Record<string, unknown> | null | undefined, key: string): Record<string, unknown> {
const value = policy?.[key];
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function getPolicyString(policy: Record<string, unknown> | null | undefined, section: string, key: string, fallback: string) {
const value = getPolicySection(policy, section)[key];
return typeof value === "string" ? value : fallback;
}
function getPolicyBoolean(policy: Record<string, unknown> | 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<string | null>(null);
const [lastResult, setLastResult] = useState<unknown>(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<AdvancedPolicyData>("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<string>();
for (const issue of data?.issues ?? []) {
if (issue.projectId) ids.add(issue.projectId);
}
return [...ids];
}, [data?.issues]);
async function run(label: string, action: () => Promise<unknown>) {
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 <div style={mutedTextStyle}>Loading advanced policy editors...</div>;
}
if (query.error) {
return (
<div style={warningStyle}>
<strong>Advanced policy APIs unavailable.</strong>
<div>{query.error.message}</div>
</div>
);
}
return (
<div style={layoutStack}>
<CapabilityWarning warnings={data?.warnings ?? []} />
<div style={gridStyle}>
<div style={subtleCardStyle}>
<div style={rowStyle}>
<strong>Mode</strong>
<Pill label={data?.summary?.permissionsMode ?? "unknown"} />
</div>
<div style={mutedTextStyle}>
Active members {data?.summary?.activeMemberCount ?? 0} / {data?.summary?.memberCount ?? 0}. Explicit grants {data?.summary?.grantCount ?? 0}.
</div>
</div>
<div style={subtleCardStyle}>
<div style={rowStyle}>
<strong>Preview Decision</strong>
<Pill label={data?.preview?.allowed ? "allow" : "deny"} tone={data?.preview?.allowed ? "allow" : "deny"} />
</div>
<div style={mutedTextStyle}>{data?.preview?.explanation ?? "Select an actor and target agent."}</div>
</div>
</div>
<div style={gridStyle}>
<label style={{ display: "grid", gap: "6px" }}>
<span style={sectionHeadingStyle}>Actor agent</span>
<select style={inputStyle} value={actorAgentId} onChange={(event) => setActorAgentId(event.target.value)}>
{(data?.agents ?? []).map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
</label>
<label style={{ display: "grid", gap: "6px" }}>
<span style={sectionHeadingStyle}>Target agent</span>
<select style={inputStyle} value={targetAgentId} onChange={(event) => setTargetAgentId(event.target.value)}>
{(data?.agents ?? []).map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
</label>
<label style={{ display: "grid", gap: "6px" }}>
<span style={sectionHeadingStyle}>Project scope</span>
<select style={inputStyle} value={projectId} onChange={(event) => setProjectId(event.target.value)}>
<option value="">Any project</option>
{projectOptions.map((id) => <option key={id} value={id}>{id}</option>)}
</select>
</label>
<label style={{ display: "grid", gap: "6px" }}>
<span style={sectionHeadingStyle}>Issue context</span>
<select style={inputStyle} value={issueId} onChange={(event) => setIssueId(event.target.value)}>
<option value="">No issue</option>
{(data?.issues ?? []).map((issue) => <option key={issue.id} value={issue.id}>{issue.title}</option>)}
</select>
</label>
</div>
<div style={gridStyle}>
<div style={cardStyle}>
<div style={sectionHeadingStyle}>Agent Visibility</div>
<label style={{ display: "grid", gap: "6px" }}>
<span>Directory mode</span>
<select style={inputStyle} value={visibilityMode} onChange={(event) => setVisibilityMode(event.target.value)}>
<option value="discoverable">Discoverable</option>
<option value="private">Private</option>
</select>
</label>
<label style={{ display: "grid", gap: "6px" }}>
<span>Assignment mode</span>
<select style={inputStyle} value={assignmentMode} onChange={(event) => setAssignmentMode(event.target.value)}>
<option value="company_default">Company default</option>
<option value="protected">Protected</option>
</select>
</label>
<label style={rowStyle}>
<input type="checkbox" checked={requiresApproval} onChange={(event) => setRequiresApproval(event.target.checked)} />
<span>Require approval for protected assignment</span>
</label>
<input style={inputStyle} placeholder="Approval reason" value={approvalReason} onChange={(event) => setApprovalReason(event.target.value)} />
<button
type="button"
style={primaryButtonStyle}
disabled={!targetAgentId || busyAction !== null}
onClick={() => void run("policy", () => saveAgentPolicy({
companyId,
agentId: targetAgentId,
visibilityMode,
assignmentMode,
requiresApproval,
approvalReason,
}))}
>
{busyAction === "policy" ? "Saving..." : "Save agent policy"}
</button>
</div>
<div style={cardStyle}>
<div style={sectionHeadingStyle}>Assignment Policy</div>
<label style={{ display: "grid", gap: "6px" }}>
<span>Grant mode</span>
<select style={inputStyle} value={grantMode} onChange={(event) => setGrantMode(event.target.value)}>
<option value="scoped_agent">Scoped to selected target</option>
<option value="broad">Broad assignment</option>
<option value="clear">Clear assignment grants</option>
</select>
</label>
<button
type="button"
style={primaryButtonStyle}
disabled={!actorAgentId || busyAction !== null}
onClick={() => void run("grants", () => saveAssignmentGrant({
companyId,
actorAgentId,
targetAgentId,
projectId,
mode: grantMode,
}))}
>
{busyAction === "grants" ? "Saving..." : "Save assignment grants"}
</button>
<JsonBlock value={data?.actorGrants ?? []} />
</div>
</div>
<div style={gridStyle}>
<div style={cardStyle}>
<div style={sectionHeadingStyle}>Permission Explanation</div>
<div style={rowStyle}>
<Pill label={data?.explanation?.reason ?? "unavailable"} tone={data?.explanation?.allowed ? "allow" : "deny"} />
<span style={mutedTextStyle}>{data?.explanation?.explanation ?? "No explanation returned."}</span>
</div>
<JsonBlock value={data?.explanation ?? null} />
</div>
<div style={cardStyle}>
<div style={sectionHeadingStyle}>Current Agent Policy</div>
<JsonBlock value={data?.agentPolicy?.policy ?? null} />
</div>
</div>
<div style={cardStyle}>
<div style={rowStyle}>
<div style={sectionHeadingStyle}>Authorization Audit</div>
<button type="button" style={buttonStyle} onClick={() => query.refresh()}>Refresh</button>
</div>
<div style={{ ...gridStyle, gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))" }}>
<input style={inputStyle} placeholder="Action" value={auditAction} onChange={(event) => setAuditAction(event.target.value)} />
<select style={inputStyle} value={auditActorType} onChange={(event) => setAuditActorType(event.target.value)}>
<option value="">Any actor</option>
<option value="agent">Agent</option>
<option value="user">User</option>
<option value="plugin">Plugin</option>
<option value="system">System</option>
</select>
<input style={inputStyle} placeholder="Resource type" value={auditEntityType} onChange={(event) => setAuditEntityType(event.target.value)} />
<input style={inputStyle} placeholder="Resource id" value={auditEntityId} onChange={(event) => setAuditEntityId(event.target.value)} />
<select style={inputStyle} value={auditDecision} onChange={(event) => setAuditDecision(event.target.value)}>
<option value="">Any decision</option>
<option value="allow">Allow</option>
<option value="deny">Deny</option>
</select>
</div>
<JsonBlock value={data?.auditEntries ?? []} />
</div>
{lastResult ? <JsonBlock value={lastResult} /> : null}
</div>
);
}
export function EePermissionsCompanySettingsPage(_props: PluginCompanySettingsPageProps) {
const hostContext = useHostContext();
const companyId = hostContext.companyId;
const overview = usePluginData<Overview>("overview", companyId ? { companyId } : {});
const activate = usePluginAction("activateLicense");
const deactivate = usePluginAction("deactivateLicense");
const [activationBusy, setActivationBusy] = useState(false);
if (!companyId) return <MissingCompanyState />;
if (overview.loading && !overview.data) {
return (
<div style={layoutStack}>
<div style={cardStyle}>
<div style={mutedTextStyle}>Loading permissions overview...</div>
</div>
</div>
);
}
if (overview.error) {
return (
<div style={layoutStack}>
<div style={cardStyle}>
<div style={sectionHeadingStyle}>Permissions</div>
<strong>Could not load permissions</strong>
<div style={mutedTextStyle}>
<code>{overview.error.code}</code>: {overview.error.message}
</div>
</div>
</div>
);
}
const data = overview.data;
if (!data) {
return (
<div style={layoutStack}>
<div style={cardStyle}>
<div style={mutedTextStyle}>No data returned.</div>
</div>
</div>
);
}
if (data.license.status !== "active") {
return (
<UnlicensedState
companyId={companyId}
activating={activationBusy}
onActivate={() => {
setActivationBusy(true);
void activate({ companyId })
.then(() => overview.refresh())
.finally(() => setActivationBusy(false));
}}
/>
);
}
return (
<div style={layoutStack}>
<div style={cardStyle}>
<div style={rowStyle}>
<div style={sectionHeadingStyle}>Paperclip EE Permissions</div>
<Pill label="active" tone="allow" />
</div>
<strong>Advanced policy editing is active</strong>
<div style={mutedTextStyle}>
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.
</div>
<div>
<button
type="button"
style={buttonStyle}
disabled={activationBusy}
onClick={() => {
setActivationBusy(true);
void deactivate({ companyId })
.then(() => overview.refresh())
.finally(() => setActivationBusy(false));
}}
>
{activationBusy ? "Updating..." : "Deactivate"}
</button>
</div>
</div>
<AdvancedPolicyEditor companyId={companyId} />
</div>
);
}
@@ -0,0 +1 @@
export { EePermissionsCompanySettingsPage } from "./app.js";
@@ -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<ReturnType<PluginContext["agents"]["list"]>>;
issues: Issue[];
selected: {
actorAgentId: string | null;
targetAgentId: string | null;
projectId: string | null;
issueId: string | null;
};
agentPolicy: Awaited<ReturnType<PluginContext["authorization"]["policies"]["get"]>>;
actorGrants: PrincipalPermissionGrant[];
preview: Awaited<ReturnType<PluginContext["authorization"]["policies"]["previewAssignment"]>> | null;
explanation: Awaited<ReturnType<PluginContext["authorization"]["policies"]["explainAssignment"]>> | null;
auditEntries: PluginAuthorizationAuditEntry[];
};
function readCompanyId(params: Record<string, unknown>): 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<string, unknown>, key: string): string {
const value = params[key];
return typeof value === "string" ? value : "";
}
function readBoolean(params: Record<string, unknown>, 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<string, unknown> : 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<EePermissionsLicense> {
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);
@@ -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<EePermissionsOverview>("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<EePermissionsLicense>(
"activateLicense",
{ companyId: COMPANY_ID, activatedByUserId: "user-1" },
);
expect(activated.status).toBe("active");
expect(activated.activatedByUserId).toBe("user-1");
const overview = await harness.getData<EePermissionsOverview>("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<EePermissionsLicense>(
"deactivateLicense",
{ companyId: COMPANY_ID },
);
expect(deactivated.status).toBe("inactive");
const reverted = await harness.getData<EePermissionsOverview>("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<EePermissionsAdvancedPolicyData>("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<Record<string, unknown>>("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/);
});
});
@@ -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"
]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});
+14
View File
@@ -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";
+1
View File
@@ -1229,6 +1229,7 @@ export interface WorkerToHostMethods {
actorId?: string;
entityType?: string;
entityId?: string;
decision?: string;
limit?: number;
offset?: number;
},
+1
View File
@@ -1744,6 +1744,7 @@ export interface PluginAuthorizationClient {
actorId?: string;
entityType?: string;
entityId?: string;
decision?: string;
limit?: number;
offset?: number;
}): Promise<PluginAuthorizationAuditEntry[]>;
@@ -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();
});
});
+21 -1
View File
@@ -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<string, unknown>
: {};
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)