mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Move EE permissions plugin out of core PR
This commit is contained in:
1 parent
eca62a83e4
commit
d764e42fea
19 files changed
+21
-2470
No files matched your search
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user