Move EE permissions plugin out of core PR

This commit is contained in:
Dotta committed 2026-05-22 01:51:03 +00:00
1 parent eca62a83e4
commit d764e42fea
19 files changed
+21 -2470

No files matched your search

-1
View File
@@ -34,7 +34,6 @@ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-lo
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
COPY packages/plugins/paperclip-ee-permissions/package.json packages/plugins/paperclip-ee-permissions/
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/
COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/
@@ -1,3 +0,0 @@
dist
node_modules
*.tsbuildinfo
@@ -1,67 +0,0 @@
# @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
```
@@ -1,17 +0,0 @@
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()]);
}
@@ -1,45 +0,0 @@
{
"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"
}
}
@@ -1,28 +0,0 @@
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);
@@ -1,57 +0,0 @@
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;
File diff suppressed because it is too large. Load diff
@@ -1 +0,0 @@
export { EePermissionsCompanySettingsPage } from "./app.js";
@@ -1,574 +0,0 @@
import {
definePlugin,
runWorker,
PLUGIN_RPC_ERROR_CODES,
type HumanCompanyMembershipRole,
type MembershipStatus,
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";
const HUMAN_ROLE_VALUES: readonly HumanCompanyMembershipRole[] = ["owner", "admin", "operator", "viewer"];
const MEMBER_EDITABLE_STATUSES: ReadonlyArray<Extract<MembershipStatus, "pending" | "active" | "suspended">> = [
"pending",
"active",
"suspended",
];
const NON_MEMBER_PERMISSION_KEYS: ReadonlySet<PermissionKey> = new Set([
"tasks:assign_scope",
]);
export type EePermissionsMemberAccessData = {
companyId: string;
warnings: Array<{ code: string; message: string }>;
members: PluginAccessMember[];
agents: Awaited<ReturnType<PluginContext["agents"]["list"]>>;
};
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 readHumanRole(value: unknown): HumanCompanyMembershipRole | null {
if (value === null) return null;
if (typeof value !== "string") return null;
if (value === "") return null;
return (HUMAN_ROLE_VALUES as readonly string[]).includes(value)
? (value as HumanCompanyMembershipRole)
: null;
}
function readEditableStatus(value: unknown): Extract<MembershipStatus, "pending" | "active" | "suspended"> {
if (typeof value === "string" && (MEMBER_EDITABLE_STATUSES as readonly string[]).includes(value)) {
return value as Extract<MembershipStatus, "pending" | "active" | "suspended">;
}
return "active";
}
function readPermissionKeyArray(value: unknown): PermissionKey[] {
if (!Array.isArray(value)) return [];
const out: PermissionKey[] = [];
for (const entry of value) {
if (typeof entry === "string" && !out.includes(entry as PermissionKey)) {
out.push(entry as PermissionKey);
}
}
return out;
}
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("memberAccess", async (params) => {
const companyId = readCompanyId(params ?? {});
const license = await readLicense(ctx, companyId);
if (license.status !== "active") {
return {
companyId,
warnings: [],
members: [],
agents: [],
} satisfies EePermissionsMemberAccessData;
}
const warnings: EePermissionsMemberAccessData["warnings"] = [];
let members: PluginAccessMember[] = [];
let agents: EePermissionsMemberAccessData["agents"] = [];
try {
members = await ctx.access.members.list({ companyId });
} catch (error) {
warnings.push(formatError(error));
}
try {
agents = await ctx.agents.list({ companyId, limit: 200, offset: 0 });
} catch (error) {
warnings.push(formatError(error));
}
return { companyId, warnings, members, agents } satisfies EePermissionsMemberAccessData;
});
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("saveMemberAccess", 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 memberId = readString(input, "memberId");
if (!memberId) throw new Error("memberId is required");
const member = await ctx.access.members.get(memberId, companyId);
if (!member) throw new Error(`Membership not found: ${memberId}`);
const membershipRole = readHumanRole(input.membershipRole);
const status = readEditableStatus(input.status);
const explicitGrants = readPermissionKeyArray(input.grants)
.filter((key) => !NON_MEMBER_PERMISSION_KEYS.has(key));
const updatedMember = await ctx.access.members.update(
memberId,
{ membershipRole, status },
companyId,
);
const existing = await ctx.authorization.grants.list({
companyId,
principalType: member.principalType,
principalId: member.principalId,
});
const carriedOverNonMemberGrants = existing
.filter((grant) => NON_MEMBER_PERMISSION_KEYS.has(grant.permissionKey as PermissionKey))
.map((grant) => ({
permissionKey: grant.permissionKey as PermissionKey,
scope: grant.scope && typeof grant.scope === "object" ? grant.scope as Record<string, unknown> : null,
}));
const nextGrants = [
...explicitGrants.map((permissionKey) => ({ permissionKey, scope: null as Record<string, unknown> | null })),
...carriedOverNonMemberGrants,
];
const grants = await ctx.authorization.grants.set({
companyId,
principalType: member.principalType,
principalId: member.principalId,
grants: nextGrants,
});
return {
member: { ...updatedMember, grants },
};
});
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);
@@ -1,394 +0,0 @@
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 EePermissionsMemberAccessData,
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";
const MEMBER_USER_ID = "00000000-0000-4000-8000-0000000000c1";
const MEMBER_RECORD_ID = "00000000-0000-4000-8000-0000000000d1";
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([
{
principalType: "agent",
principalId: ACTOR_AGENT_ID,
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: TARGET_AGENT_ID },
},
]);
});
it("returns empty member access while unlicensed", async () => {
const harness = createTestHarness({
manifest,
capabilities: manifest.capabilities,
});
await plugin.definition.setup(harness.ctx);
const data = await harness.getData<EePermissionsMemberAccessData>("memberAccess", {
companyId: COMPANY_ID,
});
expect(data.companyId).toBe(COMPANY_ID);
expect(data.members).toEqual([]);
expect(data.warnings).toEqual([]);
});
it("loads seeded company members once licensed", async () => {
const harness = createTestHarness({
manifest,
capabilities: manifest.capabilities,
});
harness.seed({
accessMembers: [
{
id: MEMBER_RECORD_ID,
companyId: COMPANY_ID,
principalType: "user",
principalId: MEMBER_USER_ID,
status: "active",
membershipRole: "operator",
grants: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
principalGrants: [
{
id: "grant-1",
companyId: COMPANY_ID,
principalType: "user",
principalId: MEMBER_USER_ID,
permissionKey: "users:invite",
scope: null,
grantedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
await plugin.definition.setup(harness.ctx);
await harness.performAction("activateLicense", { companyId: COMPANY_ID });
const data = await harness.getData<EePermissionsMemberAccessData>("memberAccess", {
companyId: COMPANY_ID,
});
expect(data.members).toHaveLength(1);
const [member] = data.members;
expect(member.principalId).toBe(MEMBER_USER_ID);
expect(member.membershipRole).toBe("operator");
expect(member.grants.map((grant) => grant.permissionKey)).toEqual(["users:invite"]);
});
it("saves member role, status, and grants atomically", async () => {
const harness = createTestHarness({
manifest,
capabilities: manifest.capabilities,
});
harness.seed({
accessMembers: [
{
id: MEMBER_RECORD_ID,
companyId: COMPANY_ID,
principalType: "user",
principalId: MEMBER_USER_ID,
status: "active",
membershipRole: "viewer",
grants: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
principalGrants: [
// pre-existing scoped grant should survive a member-level save
{
id: "grant-scoped",
companyId: COMPANY_ID,
principalType: "user",
principalId: MEMBER_USER_ID,
permissionKey: "tasks:assign_scope",
scope: { assigneeAgentId: TARGET_AGENT_ID },
grantedByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
await plugin.definition.setup(harness.ctx);
await harness.performAction("activateLicense", { companyId: COMPANY_ID });
const result = await harness.performAction<{
member: EePermissionsMemberAccessData["members"][number];
}>("saveMemberAccess", {
companyId: COMPANY_ID,
memberId: MEMBER_RECORD_ID,
membershipRole: "operator",
status: "active",
grants: ["users:invite", "tasks:assign"],
});
expect(result.member.membershipRole).toBe("operator");
expect(result.member.status).toBe("active");
const grantKeys = result.member.grants.map((grant) => grant.permissionKey).sort();
expect(grantKeys).toEqual(["tasks:assign", "tasks:assign_scope", "users:invite"]);
const reread = await harness.getData<EePermissionsMemberAccessData>("memberAccess", {
companyId: COMPANY_ID,
});
const [updated] = reread.members;
expect(updated.membershipRole).toBe("operator");
expect(updated.grants.map((grant) => grant.permissionKey).sort()).toEqual([
"tasks:assign",
"tasks:assign_scope",
"users:invite",
]);
});
it("refuses to save member access while unlicensed", async () => {
const harness = createTestHarness({
manifest,
capabilities: manifest.capabilities,
});
harness.seed({
accessMembers: [
{
id: MEMBER_RECORD_ID,
companyId: COMPANY_ID,
principalType: "user",
principalId: MEMBER_USER_ID,
status: "active",
membershipRole: "viewer",
grants: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
});
await plugin.definition.setup(harness.ctx);
await expect(
harness.performAction("saveMemberAccess", {
companyId: COMPANY_ID,
memberId: MEMBER_RECORD_ID,
membershipRole: "operator",
status: "active",
grants: [],
}),
).rejects.toThrow(/permissions mode is not active/);
});
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/);
});
});
@@ -1,27 +0,0 @@
{
"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"
]
}
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});
@@ -233,7 +233,7 @@ describeEmbeddedPostgres("authorization service", () => {
requiresApproval: true,
approvalReason: "Production deployment authority",
},
managedBy: "paperclip-ee-permissions",
managedBy: "permissions-extension",
},
},
});
@@ -270,7 +270,7 @@ describeEmbeddedPostgres("authorization service", () => {
protectedAgent: {
requiresApproval: false,
},
managedBy: "paperclip-ee-permissions",
managedBy: "permissions-extension",
},
},
});
@@ -79,7 +79,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
})
.returning()
.then((rows) => rows[0]!);
const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub());
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
await expect(
services.authorization.setGrants({
@@ -97,7 +97,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
it("redacts invite token hashes and sensitive defaults from plugin invite reads", async () => {
const company = await createCompany(db, "PAZ");
const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub());
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
const created = await services.access.createInvite({
companyId: company.id,
@@ -124,7 +124,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
it("filters authorization audit entries by allow or deny decision details", async () => {
const company = await createCompany(db, "PAU");
const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub());
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
await db.insert(activityLog).values([
{
companyId: company.id,
@@ -198,7 +198,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
membershipRole: "member",
});
const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub());
const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub());
const updatedPolicy = await services.authorization.updatePolicy({
companyId: company.id,
resourceType: "agent",
@@ -212,7 +212,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () =>
requiresApproval: true,
approvalReason: "Needs board approval",
},
managedBy: "paperclip-ee-permissions",
managedBy: "permissions-extension",
},
});
const input = {
@@ -175,8 +175,8 @@ describe("CompanySettingsSidebar", () => {
exportName: "PermissionsPage",
routePath: "permissions",
pluginId: "plugin-1",
pluginKey: "paperclip-ee",
pluginDisplayName: "Paperclip EE",
pluginKey: "permissions-extension",
pluginDisplayName: "Permissions Extension",
pluginVersion: "0.1.0",
},
],
+5 -5
View File
@@ -209,7 +209,7 @@ describe("CompanyAccess", () => {
expect(container.textContent).toContain("Manage the people who can work in Paperclip");
expect(container.textContent).toContain("Members can collaborate across the company by default");
expect(container.textContent).toContain("Advanced permission controls are available in Paperclip EE");
expect(container.textContent).toContain("Core keeps this page focused on membership");
expect(container.textContent).toContain("Humans");
expect(container.textContent).toContain("Pending human joins");
expect(container.textContent).toContain("User account");
@@ -404,7 +404,7 @@ describe("CompanyAccess", () => {
});
});
it("redirects legacy access deep links to the EE permissions route when installed", async () => {
it("redirects legacy access deep links to the permissions extension route when installed", async () => {
mockUsePluginSlots.mockReturnValue({
slots: [
{
@@ -412,7 +412,7 @@ describe("CompanyAccess", () => {
id: "permissions",
displayName: "Permissions",
routePath: "permissions",
pluginKey: "paperclip-ee",
pluginKey: "permissions-extension",
},
],
isLoading: false,
@@ -440,7 +440,7 @@ describe("CompanyAccess", () => {
});
});
it("shows a read-only EE unavailable fallback for legacy access deep links", async () => {
it("shows a read-only unavailable fallback for legacy access deep links", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -456,7 +456,7 @@ describe("CompanyAccess", () => {
await flushReact();
expect(container.textContent).toContain("Advanced Permissions");
expect(container.textContent).toContain("Paperclip EE unavailable");
expect(container.textContent).toContain("Advanced permissions unavailable");
expect(container.textContent).toContain("Open Members");
expect(container.textContent).toContain("Open Invites");
+5 -5
View File
@@ -244,7 +244,7 @@ export function CompanyAccess() {
Manage the people who can work in {selectedCompany?.name}. Members can collaborate across the company by default.
</p>
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Advanced permission controls are available in Paperclip EE. Core keeps this page focused on membership, invite approvals, and safe member removal.
Core keeps this page focused on membership, invite approvals, and safe member removal.
</div>
</div>
@@ -545,7 +545,7 @@ export function CompanyAccessLegacyRoute() {
}
if (isLoading) {
return <div className="text-sm text-muted-foreground">Checking for Paperclip EE permissions...</div>;
return <div className="text-sm text-muted-foreground">Checking for advanced permission extensions...</div>;
}
return (
@@ -556,15 +556,15 @@ export function CompanyAccessLegacyRoute() {
<h1 className="text-lg font-semibold">Advanced Permissions</h1>
</div>
<p className="text-sm text-muted-foreground">
Advanced access, scoped assignment, and explicit grant controls are now owned by the Paperclip EE Permissions plugin.
Advanced access, scoped assignment, and explicit grant controls are provided by installed company settings extensions.
</p>
</div>
<div className="space-y-4 rounded-xl border border-border px-5 py-5">
<div className="space-y-2">
<h2 className="text-sm font-semibold">Paperclip EE unavailable</h2>
<h2 className="text-sm font-semibold">Advanced permissions unavailable</h2>
<p className="text-sm text-muted-foreground">
Core Paperclip keeps enforcing company boundaries and any existing restrictive policy data, but editing advanced permissions requires Paperclip EE.
Core Paperclip keeps enforcing company boundaries and any existing restrictive policy data, but editing advanced permissions requires an installed extension.
</p>
{errorMessage ? (
<p className="text-sm text-destructive">Plugin extensions unavailable: {errorMessage}</p>
@@ -91,8 +91,8 @@ describe("CompanySettingsPluginPage", () => {
exportName: "PermissionsPage",
routePath: "permissions",
pluginId: "plugin-1",
pluginKey: "paperclip-ee",
pluginDisplayName: "Paperclip EE",
pluginKey: "permissions-extension",
pluginDisplayName: "Permissions Extension",
pluginVersion: "0.1.0",
},
],