From cdee5ea706f6867ac5a71a2c1725a09edd9bb229 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 18 May 2026 12:12:11 -0500 Subject: [PATCH] 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 --- .../paperclip-ee-permissions/src/ui/app.tsx | 320 +++++++++++++++++- .../paperclip-ee-permissions/src/worker.ts | 115 +++++++ .../tests/plugin.spec.ts | 167 ++++++++- packages/plugins/sdk/src/index.ts | 7 + packages/plugins/sdk/src/testing.ts | 120 ++++++- 5 files changed, 711 insertions(+), 18 deletions(-) diff --git a/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx b/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx index 90852da649b..2eff30b0ec4 100644 --- a/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx +++ b/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx @@ -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 = { + owner: "Owner", + admin: "Admin", + operator: "Operator", + viewer: "Viewer", +}; + +const MEMBER_PERMISSION_KEYS: ReadonlyArray = [ + "agents:create", + "users:invite", + "users:manage_permissions", + "tasks:assign", + "tasks:manage_active_checkouts", + "joins:approve", + "environments:manage", +]; + +const PERMISSION_LABELS: Record = { + "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 = { + 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 | 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 (
- Some advanced data could not be loaded. + {denied ? "Some advanced data is unavailable." : "Some advanced data could not be loaded."} +
+ {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."} +
    {warnings.map((warning, index) => (
  • @@ -283,6 +353,253 @@ function getPolicyBoolean(policy: Record | null | undefined, se return typeof value === "boolean" ? value : fallback; } +function MembersPanel({ companyId }: { companyId: string }) { + const query = usePluginData("memberAccess", { companyId }); + const saveMemberAccess = usePluginAction("saveMemberAccess"); + const [editingMemberId, setEditingMemberId] = useState(null); + const [draftRole, setDraftRole] = useState(""); + const [draftStatus, setDraftStatus] = useState("active"); + const [draftGrants, setDraftGrants] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const members = query.data?.members ?? []; + const editingMember = useMemo( + () => members.find((member) => member.id === editingMemberId) ?? null, + [members, editingMemberId], + ); + const implicitGrantKeys = useMemo( + () => (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
    Loading members...
    ; + } + + if (query.error) { + return ( +
    + Could not load company members. +
    {query.error.message}
    +
    + ); + } + + 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 ( +
    + + +
    +
    +
    Members
    + +
    +
    + Roles, status, and explicit permission grants for users and agents in this company. Saves through capability-gated plugin SDK calls; data lives in core. +
    + {humanMembers.length === 0 && agentMembers.length === 0 ? ( +
    No company members yet.
    + ) : null} + + +
    + + {editingMember ? ( +
    +
    +
    Edit member
    + +
    +
    + Principal {editingMember.principalId} +
    + +
    + + +
    + +
    +
    + Implicit grants from role + {draftRole ? : } +
    +
    + {draftRole + ? `${HUMAN_ROLE_LABELS[draftRole]} already includes these permissions automatically.` + : "No role selected, so this member has no implicit grants right now."} +
    + {implicitGrantKeys.length > 0 ? ( +
    + {implicitGrantKeys.map((permissionKey) => ( + + ))} +
    + ) : null} +
    + +
    +
    Explicit grants
    +
    + Explicit grants persist even when the role changes. Scoped assignment grants are managed in the policy editor below. +
    +
    + {MEMBER_PERMISSION_KEYS.map((permissionKey) => { + const isImplicit = implicitGrantKeys.includes(permissionKey); + const isChecked = draftGrants.has(permissionKey); + return ( + + ); + })} +
    +
    + + {error ?
    Could not save: {error}
    : null} + +
    + + +
    +
    + ) : null} +
    + ); +} + +function MembersTable({ + members, + label, + emptyLabel, + onEdit, +}: { + members: MemberRecord[]; + label: string; + emptyLabel: string; + onEdit: (memberId: string) => void; +}) { + return ( +
    +
    + {label} + +
    + {members.length === 0 ? ( +
    {emptyLabel}
    + ) : ( +
    + {members.map((member) => ( +
    +
    + {member.principalId} +
    + Role {member.membershipRole ?? "unset"} • Status {member.status} • {member.grants.length} grant{member.grants.length === 1 ? "" : "s"} +
    +
    + +
    + ))} +
    + )} +
    + ); +} + function AdvancedPolicyEditor({ companyId }: { companyId: string }) { const saveAgentPolicy = usePluginAction("saveAgentPolicy"); const saveAssignmentGrant = usePluginAction("saveAssignmentGrant"); @@ -617,6 +934,7 @@ export function EePermissionsCompanySettingsPage(_props: PluginCompanySettingsPa
+ ); diff --git a/packages/plugins/paperclip-ee-permissions/src/worker.ts b/packages/plugins/paperclip-ee-permissions/src/worker.ts index 2a1d8e5965f..abf5a23eeba 100644 --- a/packages/plugins/paperclip-ee-permissions/src/worker.ts +++ b/packages/plugins/paperclip-ee-permissions/src/worker.ts @@ -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> = [ + "pending", + "active", + "suspended", +]; +const NON_MEMBER_PERMISSION_KEYS: ReadonlySet = 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 { + if (typeof value === "string" && (MEMBER_EDITABLE_STATUSES as readonly string[]).includes(value)) { + return value as Extract; + } + 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 : null, + })); + const nextGrants = [ + ...explicitGrants.map((permissionKey) => ({ permissionKey, scope: null as Record | 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); diff --git a/packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts b/packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts index 5a76af7305c..d912b3468a9 100644 --- a/packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts +++ b/packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts @@ -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("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("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("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 () => { diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index dad9364b305..27664664b58 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -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"; diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 083c2f0cdc3..299af7c6866 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -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): void; /** Dispatch a host or plugin event to registered handlers. */ @@ -440,6 +446,39 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const issueDocuments = new Map(); const agents = new Map(); const goals = new Map(); + const accessMembers = new Map(); + const principalGrants = new Map(); + + 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 | 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(); const executionWorkspaces = new Map(); const localFolderStatuses = new Map(); @@ -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 };