From bca338c19a27de4804369124868a8b81e05c901b Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 22 May 2026 01:50:37 +0000 Subject: [PATCH] Backfill principal access compatibility --- ...ackfill_principal_access_compatibility.sql | 75 ++++++++ packages/db/src/migrations/meta/_journal.json | 7 + server/src/__tests__/access-service.test.ts | 181 ++++++++++++++++++ .../src/__tests__/company-portability.test.ts | 1 + server/src/board-claim.ts | 12 ++ server/src/index.ts | 5 + server/src/routes/companies.ts | 9 +- server/src/services/access.ts | 22 +++ server/src/services/company-portability.ts | 9 +- server/src/services/index.ts | 6 + .../principal-access-compatibility.ts | 141 ++++++++++++++ 11 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql create mode 100644 server/src/services/principal-access-compatibility.ts diff --git a/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql b/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql new file mode 100644 index 00000000000..6e78c7e5083 --- /dev/null +++ b/packages/db/src/migrations/0088_backfill_principal_access_compatibility.sql @@ -0,0 +1,75 @@ +INSERT INTO "company_memberships" ( + "company_id", + "principal_type", + "principal_id", + "status", + "membership_role", + "created_at", + "updated_at" +) +SELECT + "company_id", + 'agent', + "id", + 'active', + 'member', + now(), + now() +FROM "agents" +WHERE "status" NOT IN ('pending_approval', 'terminated') +ON CONFLICT ( + "company_id", + "principal_type", + "principal_id" +) DO NOTHING; + +INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" +) +SELECT + memberships."company_id", + 'user', + memberships."principal_id", + role_defaults."permission_key", + NULL, + NULL, + now(), + now() +FROM "company_memberships" memberships +JOIN ( + VALUES + ('owner', 'agents:create'), + ('owner', 'environments:manage'), + ('owner', 'users:invite'), + ('owner', 'users:manage_permissions'), + ('owner', 'tasks:assign'), + ('owner', 'joins:approve'), + ('admin', 'agents:create'), + ('admin', 'environments:manage'), + ('admin', 'users:invite'), + ('admin', 'tasks:assign'), + ('admin', 'joins:approve'), + ('operator', 'tasks:assign') +) AS role_defaults("membership_role", "permission_key") + ON role_defaults."membership_role" = CASE + WHEN memberships."membership_role" = 'owner' THEN 'owner' + WHEN memberships."membership_role" = 'admin' THEN 'admin' + WHEN memberships."membership_role" = 'viewer' THEN 'viewer' + WHEN memberships."membership_role" = 'member' THEN 'operator' + ELSE 'operator' + END +WHERE memberships."principal_type" = 'user' + AND memberships."status" = 'active' +ON CONFLICT ( + "company_id", + "principal_type", + "principal_id", + "permission_key" +) DO NOTHING; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 79c31aad10b..7aaa96d3da3 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -617,6 +617,13 @@ "when": 1779360000000, "tag": "0087_backfill_environment_manage_human_defaults", "breakpoints": true + }, + { + "idx": 88, + "version": "7", + "when": 1779446400000, + "tag": "0088_backfill_principal_access_compatibility", + "breakpoints": true } ] } diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts index 3148ad8c593..a0daac807aa 100644 --- a/server/src/__tests__/access-service.test.ts +++ b/server/src/__tests__/access-service.test.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { + agents, companies, companyMemberships, createDb, @@ -15,6 +16,7 @@ import { } from "./helpers/embedded-postgres.js"; import { accessService } from "../services/access.js"; import { grantsForHumanRole } from "../services/company-member-roles.js"; +import { backfillPrincipalAccessCompatibility } from "../services/principal-access-compatibility.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -57,6 +59,7 @@ describeEmbeddedPostgres("access service", () => { await db.delete(issues); await db.delete(principalPermissionGrants); await db.delete(instanceUserRoles); + await db.delete(agents); await db.delete(companyMemberships); await db.delete(companies); }); @@ -267,6 +270,184 @@ describeEmbeddedPostgres("access service", () => { await expect(access.canUser(company.id, viewer.principalId, "environments:manage")).resolves.toBe(false); }); + it("backfills pre-upgrade human memberships with missing role grants without replacing custom grants", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const scopedEnvironmentGrant = { environmentId: "env-1" }; + const humanRows = await db + .insert(companyMemberships) + .values([ + { + companyId: company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }, + { + companyId: company.id, + principalType: "user", + principalId: `operator-${randomUUID()}`, + status: "active", + membershipRole: "operator", + }, + { + companyId: company.id, + principalType: "user", + principalId: `viewer-${randomUUID()}`, + status: "active", + membershipRole: "viewer", + }, + { + companyId: company.id, + principalType: "user", + principalId: `legacy-${randomUUID()}`, + status: "active", + membershipRole: null, + }, + ]) + .returning(); + const admin = humanRows[0]!; + const operator = humanRows[1]!; + const viewer = humanRows[2]!; + const legacyMember = humanRows[3]!; + + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: owner.principalId, + permissionKey: "environments:manage", + scope: scopedEnvironmentGrant, + grantedByUserId: "custom-author", + }); + + const first = await backfillPrincipalAccessCompatibility(db); + const second = await backfillPrincipalAccessCompatibility(db); + + expect(first.humanGrantsInserted).toBeGreaterThan(0); + expect(second.humanGrantsInserted).toBe(0); + await expect(accessService(db).canUser(company.id, admin.principalId, "environments:manage")).resolves.toBe(true); + await expect(accessService(db).canUser(company.id, operator.principalId, "tasks:assign")).resolves.toBe(true); + await expect(accessService(db).canUser(company.id, legacyMember.principalId, "tasks:assign")).resolves.toBe(true); + await expect(accessService(db).canUser(company.id, viewer.principalId, "tasks:assign")).resolves.toBe(false); + + const ownerEnvironmentGrants = await db + .select() + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalId, owner.principalId), + eq(principalPermissionGrants.permissionKey, "environments:manage"), + ), + ); + expect(ownerEnvironmentGrants).toHaveLength(1); + expect(ownerEnvironmentGrants[0]?.scope).toEqual(scopedEnvironmentGrant); + expect(ownerEnvironmentGrants[0]?.grantedByUserId).toBe("custom-author"); + }); + + it("backfills non-terminal agents as active company members without reviving pending or terminated agents", async () => { + const { company } = await createCompanyWithOwner(db); + const agentRows = await db + .insert(agents) + .values([ + { + companyId: company.id, + name: `Idle ${randomUUID()}`, + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + { + companyId: company.id, + name: `Running ${randomUUID()}`, + role: "engineer", + status: "running", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + { + companyId: company.id, + name: `Pending ${randomUUID()}`, + role: "engineer", + status: "pending_approval", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + { + companyId: company.id, + name: `Terminated ${randomUUID()}`, + role: "engineer", + status: "terminated", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }, + ]) + .returning(); + const idleAgent = agentRows[0]!; + const runningAgent = agentRows[1]!; + const pendingAgent = agentRows[2]!; + const terminatedAgent = agentRows[3]!; + + const first = await backfillPrincipalAccessCompatibility(db); + const second = await backfillPrincipalAccessCompatibility(db); + + expect(first.agentMembershipsInserted).toBe(2); + expect(second.agentMembershipsInserted).toBe(0); + const memberships = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.principalType, "agent")); + expect(memberships.map((membership) => membership.principalId).sort()).toEqual([ + idleAgent.id, + runningAgent.id, + ].sort()); + expect(memberships.every((membership) => membership.status === "active")).toBe(true); + expect(memberships.every((membership) => membership.membershipRole === "member")).toBe(true); + expect(memberships.some((membership) => membership.principalId === pendingAgent.id)).toBe(false); + expect(memberships.some((membership) => membership.principalId === terminatedAgent.id)).toBe(false); + }); + + it("copies active user memberships with role-default grants for safe company imports", async () => { + const source = await createCompanyWithOwner(db); + const target = await createCompanyWithOwner(db); + const admin = await db + .insert(companyMemberships) + .values({ + companyId: source.company.id, + principalType: "user", + principalId: `admin-${randomUUID()}`, + status: "active", + membershipRole: "admin", + }) + .returning() + .then((rows) => rows[0]!); + + const access = accessService(db); + await access.copyActiveUserMemberships(source.company.id, target.company.id); + + const copiedOwnerGrants = await access.listPrincipalGrants( + target.company.id, + "user", + source.owner.principalId, + ); + const copiedAdminGrants = await access.listPrincipalGrants( + target.company.id, + "user", + admin.principalId, + ); + expect(copiedOwnerGrants.map((grant) => grant.permissionKey)).toEqual( + grantsForHumanRole("owner").map((grant) => grant.permissionKey).sort(), + ); + expect(copiedAdminGrants.map((grant) => grant.permissionKey)).toEqual( + grantsForHumanRole("admin").map((grant) => grant.permissionKey).sort(), + ); + }); + it("preserves explicit scoped environment grants when backfilling owner and admin defaults", async () => { const { company, owner } = await createCompanyWithOwner(db); const scopedGrant = { environmentId: "env-1" }; diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index df1d8e7fda0..c048052a106 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -20,6 +20,7 @@ const agentSvc = { const accessSvc = { ensureMembership: vi.fn(), + ensureRoleDefaultGrants: vi.fn(), listActiveUserMemberships: vi.fn(), copyActiveUserMemberships: vi.fn(), setPrincipalPermission: vi.fn(), diff --git a/server/src/board-claim.ts b/server/src/board-claim.ts index 4c87b732bfc..c4784406646 100644 --- a/server/src/board-claim.ts +++ b/server/src/board-claim.ts @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companies, companyMemberships, instanceUserRoles } from "@paperclipai/db"; import type { DeploymentMode } from "@paperclipai/shared"; +import { ensureHumanRoleDefaultGrants } from "./services/principal-access-compatibility.js"; const LOCAL_BOARD_USER_ID = "local-board"; const CLAIM_TTL_MS = 1000 * 60 * 60 * 24; @@ -89,6 +90,7 @@ export async function claimBoardOwnership( const status = getChallengeStatus(opts.token, opts.code); if (status !== "available") return { status }; + const claimedCompanyIds: string[] = []; await db.transaction(async (tx) => { const existingTargetAdmin = await tx .select({ id: instanceUserRoles.id }) @@ -108,6 +110,7 @@ export async function claimBoardOwnership( const allCompanies = await tx.select({ id: companies.id }).from(companies); for (const company of allCompanies) { + claimedCompanyIds.push(company.id); const existing = await tx .select({ id: companyMemberships.id, status: companyMemberships.status }) .from(companyMemberships) @@ -140,6 +143,15 @@ export async function claimBoardOwnership( } }); + for (const companyId of claimedCompanyIds) { + await ensureHumanRoleDefaultGrants(db, { + companyId, + principalId: opts.userId, + membershipRole: "owner", + grantedByUserId: opts.userId, + }); + } + if (activeChallenge && activeChallenge.token === opts.token) { activeChallenge.claimedAt = new Date(); activeChallenge.claimedByUserId = opts.userId; diff --git a/server/src/index.ts b/server/src/index.ts index c039f7584f1..bb89a46e567 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -30,6 +30,7 @@ import { logger } from "./middleware/logger.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { feedbackService, + backfillPrincipalAccessCompatibility, heartbeatService, instanceSettingsService, reconcilePersistedRuntimeServicesOnStartup, @@ -512,6 +513,10 @@ export async function startServer(): Promise { if (config.deploymentMode === "local_trusted") { await ensureLocalTrustedBoardPrincipal(db as any); } + const accessBackfill = await backfillPrincipalAccessCompatibility(db as any); + if (accessBackfill.agentMembershipsInserted > 0 || accessBackfill.humanGrantsInserted > 0) { + logger.info(accessBackfill, "Backfilled principal access compatibility records"); + } if (config.deploymentMode === "authenticated") { const { createBetterAuthHandler, diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 96a004ac039..2de26afbe13 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -271,7 +271,14 @@ export function companyRoutes(db: Db, storage?: StorageService) { throw forbidden("Instance admin required"); } const company = await svc.create(req.body); - await access.ensureMembership(company.id, "user", req.actor.userId ?? "local-board", "owner", "active"); + const ownerPrincipalId = req.actor.userId ?? "local-board"; + await access.ensureMembership(company.id, "user", ownerPrincipalId, "owner", "active"); + await access.ensureRoleDefaultGrants( + company.id, + ownerPrincipalId, + "owner", + req.actor.userId ?? null, + ); await logActivity(db, { companyId: company.id, actorType: "user", diff --git a/server/src/services/access.ts b/server/src/services/access.ts index b1b70ca3cbc..ad58db87df4 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -10,6 +10,7 @@ import { import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; import { conflict } from "../errors.js"; import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js"; +import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js"; type MembershipRow = typeof companyMemberships.$inferSelect; type GrantInput = { @@ -622,10 +623,30 @@ export function accessService(db: Db) { membership.membershipRole, "active", ); + await ensureHumanRoleDefaultGrants(db, { + companyId: targetCompanyId, + principalId: membership.principalId, + membershipRole: membership.membershipRole, + grantedByUserId: null, + }); } return sourceMemberships; } + async function ensureRoleDefaultGrants( + companyId: string, + principalId: string, + membershipRole: string | null | undefined, + grantedByUserId: string | null, + ) { + return ensureHumanRoleDefaultGrants(db, { + companyId, + principalId, + membershipRole, + grantedByUserId, + }); + } + async function listPrincipalGrants( companyId: string, principalType: PrincipalType, @@ -783,6 +804,7 @@ export function accessService(db: Db) { listMembers, listActiveUserMemberships, copyActiveUserMemberships, + ensureRoleDefaultGrants, archiveMember, setMemberPermissions, updateMemberAndPermissions, diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index b1a4ede3969..1b9dfc85794 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -4118,7 +4118,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (mode === "agent_safe" && options?.sourceCompanyId) { await access.copyActiveUserMemberships(options.sourceCompanyId, created.id); } else { - await access.ensureMembership(created.id, "user", actorUserId ?? "board", "owner", "active"); + const ownerPrincipalId = actorUserId ?? "board"; + await access.ensureMembership(created.id, "user", ownerPrincipalId, "owner", "active"); + await access.ensureRoleDefaultGrants( + created.id, + ownerPrincipalId, + "owner", + actorUserId ?? null, + ); } targetCompany = created; companyAction = "created"; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index a43fac00faa..a8570d2d27f 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -44,6 +44,12 @@ export { sidebarBadgeService } from "./sidebar-badges.js"; export { sidebarPreferenceService } from "./sidebar-preferences.js"; export { inboxDismissalService } from "./inbox-dismissals.js"; export { accessService } from "./access.js"; +export { + backfillPrincipalAccessCompatibility, + ensureHumanRoleDefaultGrants, + insertMissingPrincipalGrants, + type PrincipalAccessCompatibilityBackfillStats, +} from "./principal-access-compatibility.js"; export { authorizationService } from "./authorization.js"; export type { AuthorizationAction, diff --git a/server/src/services/principal-access-compatibility.ts b/server/src/services/principal-access-compatibility.ts new file mode 100644 index 00000000000..cb1e86be82f --- /dev/null +++ b/server/src/services/principal-access-compatibility.ts @@ -0,0 +1,141 @@ +import { and, eq, notInArray } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, companyMemberships, principalPermissionGrants } from "@paperclipai/db"; +import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; +import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js"; + +type GrantInput = { + permissionKey: PermissionKey; + scope?: Record | null; +}; + +export type PrincipalAccessCompatibilityBackfillStats = { + agentMembershipsInserted: number; + humanGrantsInserted: number; +}; + +export async function insertMissingPrincipalGrants( + db: Db, + input: { + companyId: string; + principalType: PrincipalType; + principalId: string; + grants: GrantInput[]; + grantedByUserId: string | null; + }, +): Promise { + if (input.grants.length === 0) return 0; + + const now = new Date(); + const inserted = await db + .insert(principalPermissionGrants) + .values( + input.grants.map((grant) => ({ + companyId: input.companyId, + principalType: input.principalType, + principalId: input.principalId, + permissionKey: grant.permissionKey, + scope: grant.scope ?? null, + grantedByUserId: input.grantedByUserId, + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing({ + target: [ + principalPermissionGrants.companyId, + principalPermissionGrants.principalType, + principalPermissionGrants.principalId, + principalPermissionGrants.permissionKey, + ], + }) + .returning({ id: principalPermissionGrants.id }); + + return inserted.length; +} + +export async function ensureHumanRoleDefaultGrants( + db: Db, + input: { + companyId: string; + principalId: string; + membershipRole: string | null | undefined; + grantedByUserId: string | null; + }, +): Promise { + const role = normalizeHumanRole(input.membershipRole, "operator"); + return insertMissingPrincipalGrants(db, { + companyId: input.companyId, + principalType: "user", + principalId: input.principalId, + grants: grantsForHumanRole(role), + grantedByUserId: input.grantedByUserId, + }); +} + +export async function backfillPrincipalAccessCompatibility( + db: Db, +): Promise { + const now = new Date(); + const nonTerminalAgents = await db + .select({ + companyId: agents.companyId, + principalId: agents.id, + }) + .from(agents) + .where(notInArray(agents.status, ["pending_approval", "terminated"])); + + const agentMembershipsInserted = nonTerminalAgents.length > 0 + ? await db + .insert(companyMemberships) + .values( + nonTerminalAgents.map((agent) => ({ + companyId: agent.companyId, + principalType: "agent", + principalId: agent.principalId, + status: "active", + membershipRole: "member", + createdAt: now, + updatedAt: now, + })), + ) + .onConflictDoNothing({ + target: [ + companyMemberships.companyId, + companyMemberships.principalType, + companyMemberships.principalId, + ], + }) + .returning({ id: companyMemberships.id }) + .then((rows) => rows.length) + : 0; + + const activeHumanMemberships = await db + .select({ + companyId: companyMemberships.companyId, + principalId: companyMemberships.principalId, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + ), + ); + + let humanGrantsInserted = 0; + for (const membership of activeHumanMemberships) { + humanGrantsInserted += await ensureHumanRoleDefaultGrants(db, { + companyId: membership.companyId, + principalId: membership.principalId, + membershipRole: membership.membershipRole, + grantedByUserId: null, + }); + } + + return { + agentMembershipsInserted, + humanGrantsInserted, + }; +}