mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Port advanced company access UX to EE permissions plugin
Adds members + roles/grants/scopes editor to the Paperclip EE permissions plugin company settings page so EE can edit member access through capability- gated SDK methods. Companion test harness improvements let plugin tests seed members and grants and observe persisted writes through `access.members.*` and `authorization.grants.*`. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
022616389c
commit
cdee5ea706
5 files changed
+711
-18
No files matched your search
@@ -6,6 +6,70 @@ import {
|
||||
type PluginCompanySettingsPageProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
type HumanCompanyMembershipRole = "owner" | "admin" | "operator" | "viewer";
|
||||
type MemberEditableStatus = "pending" | "active" | "suspended";
|
||||
type MemberPrincipalType = "user" | "agent";
|
||||
type PermissionKey =
|
||||
| "agents:create"
|
||||
| "environments:manage"
|
||||
| "users:invite"
|
||||
| "users:manage_permissions"
|
||||
| "tasks:assign"
|
||||
| "tasks:assign_scope"
|
||||
| "tasks:manage_active_checkouts"
|
||||
| "joins:approve";
|
||||
|
||||
const HUMAN_ROLE_LABELS: Record<HumanCompanyMembershipRole, string> = {
|
||||
owner: "Owner",
|
||||
admin: "Admin",
|
||||
operator: "Operator",
|
||||
viewer: "Viewer",
|
||||
};
|
||||
|
||||
const MEMBER_PERMISSION_KEYS: ReadonlyArray<PermissionKey> = [
|
||||
"agents:create",
|
||||
"users:invite",
|
||||
"users:manage_permissions",
|
||||
"tasks:assign",
|
||||
"tasks:manage_active_checkouts",
|
||||
"joins:approve",
|
||||
"environments:manage",
|
||||
];
|
||||
|
||||
const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
||||
"agents:create": "Create agents",
|
||||
"users:invite": "Invite humans and agents",
|
||||
"users:manage_permissions": "Manage members and grants",
|
||||
"tasks:assign": "Assign tasks",
|
||||
"tasks:assign_scope": "Assign scoped tasks",
|
||||
"tasks:manage_active_checkouts": "Manage active task checkouts",
|
||||
"joins:approve": "Approve join requests",
|
||||
"environments:manage": "Manage environments",
|
||||
};
|
||||
|
||||
const IMPLICIT_ROLE_GRANTS: Record<HumanCompanyMembershipRole, PermissionKey[]> = {
|
||||
owner: ["agents:create", "users:invite", "users:manage_permissions", "tasks:assign", "joins:approve"],
|
||||
admin: ["agents:create", "users:invite", "tasks:assign", "joins:approve"],
|
||||
operator: ["tasks:assign"],
|
||||
viewer: [],
|
||||
};
|
||||
|
||||
type MemberRecord = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
principalType: MemberPrincipalType;
|
||||
principalId: string;
|
||||
status: MemberEditableStatus | "archived";
|
||||
membershipRole: HumanCompanyMembershipRole | string | null;
|
||||
grants: Array<{ permissionKey: string; scope: Record<string, unknown> | null }>;
|
||||
};
|
||||
|
||||
type MemberAccessData = {
|
||||
companyId: string;
|
||||
warnings: Array<{ code: string; message: string }>;
|
||||
members: MemberRecord[];
|
||||
};
|
||||
|
||||
type LicenseState = {
|
||||
status: "active" | "inactive";
|
||||
activatedAt?: string;
|
||||
@@ -212,9 +276,15 @@ function getErrorMessage(error: unknown): string {
|
||||
|
||||
function CapabilityWarning({ warnings }: { warnings: Overview["warnings"] }) {
|
||||
if (warnings.length === 0) return null;
|
||||
const denied = warnings.some((warning) => warning.code === "CAPABILITY_DENIED");
|
||||
return (
|
||||
<div style={warningStyle}>
|
||||
<strong>Some advanced data could not be loaded.</strong>
|
||||
<strong>{denied ? "Some advanced data is unavailable." : "Some advanced data could not be loaded."}</strong>
|
||||
<div style={mutedTextStyle}>
|
||||
{denied
|
||||
? "The plugin is missing one or more capability grants. Install the latest version or re-activate the plugin to restore the missing surfaces."
|
||||
: "Existing restrictions remain enforced by core. Retry once the underlying service is reachable."}
|
||||
</div>
|
||||
<ul style={{ margin: "6px 0 0", paddingLeft: "18px" }}>
|
||||
{warnings.map((warning, index) => (
|
||||
<li key={`${warning.code}-${index}`}>
|
||||
@@ -283,6 +353,253 @@ function getPolicyBoolean(policy: Record<string, unknown> | null | undefined, se
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function MembersPanel({ companyId }: { companyId: string }) {
|
||||
const query = usePluginData<MemberAccessData>("memberAccess", { companyId });
|
||||
const saveMemberAccess = usePluginAction("saveMemberAccess");
|
||||
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
|
||||
const [draftRole, setDraftRole] = useState<HumanCompanyMembershipRole | "">("");
|
||||
const [draftStatus, setDraftStatus] = useState<MemberEditableStatus>("active");
|
||||
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const members = query.data?.members ?? [];
|
||||
const editingMember = useMemo(
|
||||
() => members.find((member) => member.id === editingMemberId) ?? null,
|
||||
[members, editingMemberId],
|
||||
);
|
||||
const implicitGrantKeys = useMemo<PermissionKey[]>(
|
||||
() => (draftRole ? IMPLICIT_ROLE_GRANTS[draftRole] : []),
|
||||
[draftRole],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingMember) return;
|
||||
const role = editingMember.membershipRole;
|
||||
setDraftRole((role && role in HUMAN_ROLE_LABELS ? (role as HumanCompanyMembershipRole) : ""));
|
||||
setDraftStatus(
|
||||
editingMember.status === "active" || editingMember.status === "pending" || editingMember.status === "suspended"
|
||||
? editingMember.status
|
||||
: "suspended",
|
||||
);
|
||||
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey as PermissionKey)));
|
||||
setError(null);
|
||||
}, [editingMember]);
|
||||
|
||||
if (query.loading && !query.data) {
|
||||
return <div style={mutedTextStyle}>Loading members...</div>;
|
||||
}
|
||||
|
||||
if (query.error) {
|
||||
return (
|
||||
<div style={warningStyle}>
|
||||
<strong>Could not load company members.</strong>
|
||||
<div>{query.error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const humanMembers = members.filter((member) => member.principalType === "user");
|
||||
const agentMembers = members.filter((member) => member.principalType === "agent");
|
||||
const closeDialog = () => {
|
||||
setEditingMemberId(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
async function save() {
|
||||
if (!editingMember) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await saveMemberAccess({
|
||||
companyId,
|
||||
memberId: editingMember.id,
|
||||
membershipRole: draftRole || null,
|
||||
status: draftStatus,
|
||||
grants: [...draftGrants],
|
||||
});
|
||||
query.refresh();
|
||||
setEditingMemberId(null);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={layoutStack}>
|
||||
<CapabilityWarning warnings={query.data?.warnings ?? []} />
|
||||
|
||||
<div style={cardStyle}>
|
||||
<div style={rowStyle}>
|
||||
<div style={sectionHeadingStyle}>Members</div>
|
||||
<button type="button" style={buttonStyle} onClick={() => query.refresh()}>Refresh</button>
|
||||
</div>
|
||||
<div style={mutedTextStyle}>
|
||||
Roles, status, and explicit permission grants for users and agents in this company. Saves through capability-gated plugin SDK calls; data lives in core.
|
||||
</div>
|
||||
{humanMembers.length === 0 && agentMembers.length === 0 ? (
|
||||
<div style={mutedTextStyle}>No company members yet.</div>
|
||||
) : null}
|
||||
<MembersTable members={humanMembers} label="Humans" emptyLabel="No human members" onEdit={setEditingMemberId} />
|
||||
<MembersTable members={agentMembers} label="Agents" emptyLabel="No agent members" onEdit={setEditingMemberId} />
|
||||
</div>
|
||||
|
||||
{editingMember ? (
|
||||
<div style={cardStyle} role="dialog" aria-label="Edit member">
|
||||
<div style={rowStyle}>
|
||||
<div style={sectionHeadingStyle}>Edit member</div>
|
||||
<Pill label={editingMember.principalType} />
|
||||
</div>
|
||||
<div style={mutedTextStyle}>
|
||||
Principal <code>{editingMember.principalId}</code>
|
||||
</div>
|
||||
|
||||
<div style={gridStyle}>
|
||||
<label style={{ display: "grid", gap: "6px" }}>
|
||||
<span style={sectionHeadingStyle}>Company role</span>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={draftRole}
|
||||
onChange={(event) => setDraftRole(event.target.value as HumanCompanyMembershipRole | "")}
|
||||
>
|
||||
<option value="">Unset</option>
|
||||
{Object.entries(HUMAN_ROLE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ display: "grid", gap: "6px" }}>
|
||||
<span style={sectionHeadingStyle}>Membership status</span>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={draftStatus}
|
||||
onChange={(event) => setDraftStatus(event.target.value as MemberEditableStatus)}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={subtleCardStyle}>
|
||||
<div style={rowStyle}>
|
||||
<strong>Implicit grants from role</strong>
|
||||
{draftRole ? <Pill label={HUMAN_ROLE_LABELS[draftRole]} /> : <Pill label="No role" />}
|
||||
</div>
|
||||
<div style={mutedTextStyle}>
|
||||
{draftRole
|
||||
? `${HUMAN_ROLE_LABELS[draftRole]} already includes these permissions automatically.`
|
||||
: "No role selected, so this member has no implicit grants right now."}
|
||||
</div>
|
||||
{implicitGrantKeys.length > 0 ? (
|
||||
<div style={rowStyle}>
|
||||
{implicitGrantKeys.map((permissionKey) => (
|
||||
<Pill key={permissionKey} label={PERMISSION_LABELS[permissionKey]} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={sectionHeadingStyle}>Explicit grants</div>
|
||||
<div style={mutedTextStyle}>
|
||||
Explicit grants persist even when the role changes. Scoped assignment grants are managed in the policy editor below.
|
||||
</div>
|
||||
<div style={{ ...gridStyle, marginTop: "8px" }}>
|
||||
{MEMBER_PERMISSION_KEYS.map((permissionKey) => {
|
||||
const isImplicit = implicitGrantKeys.includes(permissionKey);
|
||||
const isChecked = draftGrants.has(permissionKey);
|
||||
return (
|
||||
<label key={permissionKey} style={subtleCardStyle}>
|
||||
<label style={{ ...rowStyle, gap: "10px" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(event) => {
|
||||
setDraftGrants((current) => {
|
||||
const next = new Set(current);
|
||||
if (event.target.checked) next.add(permissionKey);
|
||||
else next.delete(permissionKey);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<strong>{PERMISSION_LABELS[permissionKey]}</strong>
|
||||
<div style={{ ...mutedTextStyle, fontSize: "0.72rem" }}>
|
||||
<code>{permissionKey}</code>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
{isImplicit && !isChecked ? (
|
||||
<div style={mutedTextStyle}>Included implicitly by the {draftRole ? HUMAN_ROLE_LABELS[draftRole] : "selected"} role.</div>
|
||||
) : null}
|
||||
{isChecked ? (
|
||||
<div style={mutedTextStyle}>Stored explicitly for this member.</div>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div style={warningStyle}><strong>Could not save:</strong> {error}</div> : null}
|
||||
|
||||
<div style={rowStyle}>
|
||||
<button type="button" style={buttonStyle} disabled={busy} onClick={closeDialog}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" style={primaryButtonStyle} disabled={busy} onClick={() => void save()}>
|
||||
{busy ? "Saving..." : "Save access"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersTable({
|
||||
members,
|
||||
label,
|
||||
emptyLabel,
|
||||
onEdit,
|
||||
}: {
|
||||
members: MemberRecord[];
|
||||
label: string;
|
||||
emptyLabel: string;
|
||||
onEdit: (memberId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={subtleCardStyle}>
|
||||
<div style={rowStyle}>
|
||||
<strong>{label}</strong>
|
||||
<Pill label={`${members.length}`} />
|
||||
</div>
|
||||
{members.length === 0 ? (
|
||||
<div style={mutedTextStyle}>{emptyLabel}</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gap: "6px" }}>
|
||||
{members.map((member) => (
|
||||
<div key={member.id} style={{ ...rowStyle, justifyContent: "space-between" }}>
|
||||
<div style={{ display: "grid", gap: "2px" }}>
|
||||
<strong>{member.principalId}</strong>
|
||||
<div style={mutedTextStyle}>
|
||||
Role <code>{member.membershipRole ?? "unset"}</code> • Status <code>{member.status}</code> • {member.grants.length} grant{member.grants.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" style={buttonStyle} onClick={() => onEdit(member.id)}>Edit</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedPolicyEditor({ companyId }: { companyId: string }) {
|
||||
const saveAgentPolicy = usePluginAction("saveAgentPolicy");
|
||||
const saveAssignmentGrant = usePluginAction("saveAssignmentGrant");
|
||||
@@ -617,6 +934,7 @@ export function EePermissionsCompanySettingsPage(_props: PluginCompanySettingsPa
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MembersPanel companyId={companyId} />
|
||||
<AdvancedPolicyEditor companyId={companyId} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
definePlugin,
|
||||
runWorker,
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
type HumanCompanyMembershipRole,
|
||||
type MembershipStatus,
|
||||
type PluginAccessMember,
|
||||
type PluginAuthorizationAuditEntry,
|
||||
type PluginAuthorizationPolicySummary,
|
||||
@@ -43,6 +45,22 @@ export type EePermissionsOverview = {
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
export type EePermissionsAdvancedPolicyData = {
|
||||
companyId: string;
|
||||
summary: PluginAuthorizationPolicySummary | null;
|
||||
@@ -93,6 +111,33 @@ function readGrantMode(value: unknown): AssignmentGrantMode {
|
||||
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")
|
||||
@@ -177,6 +222,26 @@ const plugin = definePlugin({
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("memberAccess", async (params) => {
|
||||
const companyId = readCompanyId(params ?? {});
|
||||
const license = await readLicense(ctx, companyId);
|
||||
if (license.status !== "active") {
|
||||
return {
|
||||
companyId,
|
||||
warnings: [],
|
||||
members: [],
|
||||
} satisfies EePermissionsMemberAccessData;
|
||||
}
|
||||
const warnings: EePermissionsMemberAccessData["warnings"] = [];
|
||||
let members: PluginAccessMember[] = [];
|
||||
try {
|
||||
members = await ctx.access.members.list({ companyId });
|
||||
} catch (error) {
|
||||
warnings.push(formatError(error));
|
||||
}
|
||||
return { companyId, warnings, members } satisfies EePermissionsMemberAccessData;
|
||||
});
|
||||
|
||||
ctx.data.register("grants", async (params) => {
|
||||
const input = params ?? {};
|
||||
const companyId = readCompanyId(input);
|
||||
@@ -373,6 +438,56 @@ const plugin = definePlugin({
|
||||
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);
|
||||
|
||||
@@ -4,6 +4,7 @@ import manifest, { EXPORT_NAMES, SLOT_IDS } from "../src/manifest.js";
|
||||
import plugin, {
|
||||
type EePermissionsAdvancedPolicyData,
|
||||
type EePermissionsLicense,
|
||||
type EePermissionsMemberAccessData,
|
||||
type EePermissionsOverview,
|
||||
} from "../src/worker.js";
|
||||
|
||||
@@ -11,6 +12,8 @@ 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", () => {
|
||||
@@ -215,7 +218,169 @@ describe("paperclip-ee-permissions worker", () => {
|
||||
agentVisibility: { mode: "private" },
|
||||
protectedAgent: { requiresApproval: true },
|
||||
});
|
||||
expect(grants).toEqual([]);
|
||||
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 () => {
|
||||
|
||||
@@ -268,6 +268,8 @@ export type {
|
||||
PrincipalPermissionGrant,
|
||||
PrincipalType,
|
||||
PluginDatabaseClient,
|
||||
HumanCompanyMembershipRole,
|
||||
MembershipStatus,
|
||||
} from "./types.js";
|
||||
|
||||
// Manifest and constant types re-exported from @paperclipai/shared
|
||||
@@ -375,4 +377,9 @@ export {
|
||||
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
|
||||
PLUGIN_EVENT_TYPES,
|
||||
PLUGIN_BRIDGE_ERROR_CODES,
|
||||
PERMISSION_KEYS,
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLES,
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
|
||||
MEMBERSHIP_STATUSES,
|
||||
PRINCIPAL_TYPES,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -38,6 +38,10 @@ import type {
|
||||
AgentSessionEvent,
|
||||
PluginLocalFolderEntry,
|
||||
PluginLocalFolderStatus,
|
||||
PluginAccessMember,
|
||||
PrincipalPermissionGrant,
|
||||
PermissionKey,
|
||||
PrincipalType,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
PluginEnvironmentValidateConfigParams,
|
||||
@@ -73,7 +77,7 @@ export interface TestHarnessLogEntry {
|
||||
export interface TestHarness {
|
||||
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
|
||||
ctx: PluginContext;
|
||||
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
|
||||
/** Seed host entities for `ctx.companies/projects/issues/agents/goals/access/authorization` reads. */
|
||||
seed(input: {
|
||||
companies?: Company[];
|
||||
projects?: Project[];
|
||||
@@ -83,6 +87,8 @@ export interface TestHarness {
|
||||
goals?: Goal[];
|
||||
projectWorkspaces?: PluginWorkspace[];
|
||||
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
|
||||
accessMembers?: PluginAccessMember[];
|
||||
principalGrants?: PrincipalPermissionGrant[];
|
||||
}): void;
|
||||
setConfig(config: Record<string, unknown>): void;
|
||||
/** Dispatch a host or plugin event to registered handlers. */
|
||||
@@ -440,6 +446,39 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const issueDocuments = new Map<string, IssueDocument>();
|
||||
const agents = new Map<string, Agent>();
|
||||
const goals = new Map<string, Goal>();
|
||||
const accessMembers = new Map<string, PluginAccessMember>();
|
||||
const principalGrants = new Map<string, PrincipalPermissionGrant[]>();
|
||||
|
||||
function principalGrantsKey(companyId: string, principalType: PrincipalType, principalId: string) {
|
||||
return `${companyId}:${principalType}:${principalId}`;
|
||||
}
|
||||
function getPrincipalGrants(companyId: string, principalType: PrincipalType, principalId: string) {
|
||||
return principalGrants.get(principalGrantsKey(companyId, principalType, principalId)) ?? [];
|
||||
}
|
||||
function setPrincipalGrants(
|
||||
companyId: string,
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
grants: Array<{ permissionKey: PermissionKey; scope?: Record<string, unknown> | null }>,
|
||||
) {
|
||||
const stamped = grants.map((grant) => ({
|
||||
principalType,
|
||||
principalId,
|
||||
permissionKey: grant.permissionKey,
|
||||
scope: grant.scope && typeof grant.scope === "object" ? grant.scope : null,
|
||||
})) as PrincipalPermissionGrant[];
|
||||
principalGrants.set(principalGrantsKey(companyId, principalType, principalId), stamped);
|
||||
const member = [...accessMembers.values()].find(
|
||||
(entry) =>
|
||||
entry.companyId === companyId
|
||||
&& entry.principalType === principalType
|
||||
&& entry.principalId === principalId,
|
||||
);
|
||||
if (member) {
|
||||
accessMembers.set(member.id, { ...member, grants: stamped, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
return stamped;
|
||||
}
|
||||
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
||||
const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
|
||||
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
|
||||
@@ -1987,18 +2026,44 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
members: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "access.members.read");
|
||||
requireCompanyId(input.companyId);
|
||||
return [];
|
||||
const cid = requireCompanyId(input.companyId);
|
||||
const includeArchived = input.includeArchived === true;
|
||||
return [...accessMembers.values()]
|
||||
.filter((member) => member.companyId === cid)
|
||||
.filter((member) => includeArchived || member.status !== ("archived" as PluginAccessMember["status"]))
|
||||
.map((member) => ({
|
||||
...member,
|
||||
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
|
||||
}));
|
||||
},
|
||||
async get(_memberId, companyId) {
|
||||
async get(memberId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "access.members.read");
|
||||
requireCompanyId(companyId);
|
||||
return null;
|
||||
const cid = requireCompanyId(companyId);
|
||||
const member = accessMembers.get(memberId);
|
||||
if (!member || member.companyId !== cid) return null;
|
||||
return {
|
||||
...member,
|
||||
grants: getPrincipalGrants(cid, member.principalType, member.principalId),
|
||||
};
|
||||
},
|
||||
async update(memberId, _patch, companyId) {
|
||||
async update(memberId, patch, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "access.members.write");
|
||||
requireCompanyId(companyId);
|
||||
throw new Error(`Membership not found: ${memberId}`);
|
||||
const cid = requireCompanyId(companyId);
|
||||
const member = accessMembers.get(memberId);
|
||||
if (!member || member.companyId !== cid) {
|
||||
throw new Error(`Membership not found: ${memberId}`);
|
||||
}
|
||||
const updated: PluginAccessMember = {
|
||||
...member,
|
||||
membershipRole: patch.membershipRole === undefined ? member.membershipRole : patch.membershipRole,
|
||||
status: patch.status === undefined ? member.status : patch.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
accessMembers.set(memberId, updated);
|
||||
return {
|
||||
...updated,
|
||||
grants: getPrincipalGrants(cid, updated.principalType, updated.principalId),
|
||||
};
|
||||
},
|
||||
},
|
||||
invites: {
|
||||
@@ -2023,25 +2088,42 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
grants: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.grants.read");
|
||||
requireCompanyId(input.companyId);
|
||||
return [];
|
||||
const cid = requireCompanyId(input.companyId);
|
||||
if (input.principalType && input.principalId) {
|
||||
return getPrincipalGrants(cid, input.principalType, input.principalId);
|
||||
}
|
||||
const out: PrincipalPermissionGrant[] = [];
|
||||
for (const [key, grants] of principalGrants.entries()) {
|
||||
if (!key.startsWith(`${cid}:`)) continue;
|
||||
for (const grant of grants) {
|
||||
if (input.principalType && grant.principalType !== input.principalType) continue;
|
||||
if (input.principalId && grant.principalId !== input.principalId) continue;
|
||||
out.push(grant);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
async set(input) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.grants.write");
|
||||
requireCompanyId(input.companyId);
|
||||
return [];
|
||||
const cid = requireCompanyId(input.companyId);
|
||||
return setPrincipalGrants(cid, input.principalType, input.principalId, input.grants);
|
||||
},
|
||||
},
|
||||
policies: {
|
||||
async summary(companyId) {
|
||||
requireCapability(manifest, capabilitySet, "authorization.policies.read");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const members = [...accessMembers.values()].filter((member) => member.companyId === cid);
|
||||
let grantCount = 0;
|
||||
for (const [key, grants] of principalGrants.entries()) {
|
||||
if (key.startsWith(`${cid}:`)) grantCount += grants.length;
|
||||
}
|
||||
return {
|
||||
companyId: cid,
|
||||
permissionsMode: "simple",
|
||||
memberCount: 0,
|
||||
activeMemberCount: 0,
|
||||
grantCount: 0,
|
||||
memberCount: members.length,
|
||||
activeMemberCount: members.filter((member) => member.status === "active").length,
|
||||
grantCount,
|
||||
advancedPolicyAvailable: false,
|
||||
};
|
||||
},
|
||||
@@ -2172,6 +2254,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
projectWorkspaces.set(row.projectId, list);
|
||||
}
|
||||
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
|
||||
for (const row of input.accessMembers ?? []) accessMembers.set(row.id, row);
|
||||
for (const row of input.principalGrants ?? []) {
|
||||
const list = principalGrants.get(principalGrantsKey(row.companyId, row.principalType, row.principalId)) ?? [];
|
||||
list.push(row);
|
||||
principalGrants.set(principalGrantsKey(row.companyId, row.principalType, row.principalId), list);
|
||||
}
|
||||
},
|
||||
setConfig(config) {
|
||||
currentConfig = { ...config };
|
||||
|
||||
Reference in new issue
Block a user