From 2161240692b5a4cc14b4e4c275924e8d29dd82a4 Mon Sep 17 00:00:00 2001 From: cryppadotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:37:31 +0000 Subject: [PATCH] fix(auth): classify migrated role-default grants Co-Authored-By: Paperclip --- .../db/src/migrations/0278_natural_nehzno.sql | 48 +++++++++- ...-permission-grant-origin-migration.test.ts | 91 +++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 packages/db/src/principal-permission-grant-origin-migration.test.ts diff --git a/packages/db/src/migrations/0278_natural_nehzno.sql b/packages/db/src/migrations/0278_natural_nehzno.sql index da0ebf9be0..4c51a1739e 100644 --- a/packages/db/src/migrations/0278_natural_nehzno.sql +++ b/packages/db/src/migrations/0278_natural_nehzno.sql @@ -1,2 +1,48 @@ ALTER TABLE "principal_permission_grants" ADD COLUMN "grant_origin" text DEFAULT 'explicit' NOT NULL;--> statement-breakpoint -ALTER TABLE "principal_permission_grants" ADD CONSTRAINT "principal_permission_grants_origin_check" CHECK ("principal_permission_grants"."grant_origin" in ('explicit', 'role_default')); \ No newline at end of file +UPDATE "principal_permission_grants" grants +SET "grant_origin" = 'role_default' +FROM "company_memberships" memberships +WHERE grants."company_id" = memberships."company_id" + AND grants."principal_type" = 'user' + AND grants."principal_id" = memberships."principal_id" + AND memberships."principal_type" = 'user' + AND memberships."status" = 'active' + AND grants."scope" IS NULL + AND grants."granted_by_user_id" IS NULL + AND ( + (memberships."membership_role" = 'owner' AND grants."permission_key" IN ( + 'agents:create', + 'agents:configure', + 'skills:create', + 'environments:manage', + 'users:invite', + 'users:manage_permissions', + 'tasks:assign', + 'tasks:manage_active_checkouts', + 'joins:approve', + 'pipelines:write', + 'audit:view_agent_actions', + 'tools:manage_connections', + 'tools:manage_runtime', + 'tools:use', + 'tools:admin' + )) + OR (memberships."membership_role" = 'admin' AND grants."permission_key" IN ( + 'agents:create', + 'agents:configure', + 'skills:create', + 'environments:manage', + 'users:invite', + 'tasks:assign', + 'tasks:manage_active_checkouts', + 'joins:approve', + 'pipelines:write', + 'audit:view_agent_actions', + 'tools:manage_connections', + 'tools:manage_runtime', + 'tools:use', + 'tools:admin' + )) + OR (memberships."membership_role" IN ('member', 'operator') AND grants."permission_key" = 'tasks:assign') + );--> statement-breakpoint +ALTER TABLE "principal_permission_grants" ADD CONSTRAINT "principal_permission_grants_origin_check" CHECK ("principal_permission_grants"."grant_origin" in ('explicit', 'role_default')); diff --git a/packages/db/src/principal-permission-grant-origin-migration.test.ts b/packages/db/src/principal-permission-grant-origin-migration.test.ts new file mode 100644 index 0000000000..6a37d93c0d --- /dev/null +++ b/packages/db/src/principal-permission-grant-origin-migration.test.ts @@ -0,0 +1,91 @@ +import fs from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import postgres from "postgres"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./test-embedded-postgres.js"; + +const MIGRATION_FILE = "0273_round_domino.sql"; +const cleanups: Array<() => Promise> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()?.(); +}); + +async function migrationStatements() { + const content = await fs.promises.readFile( + new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), + "utf8", + ); + return content + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter(Boolean); +} + +describeEmbeddedPostgres("principal permission grant origin migration", () => { + it("marks historical role defaults without consuming explicit grants", async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-grant-origin-"); + cleanups.push(database.cleanup); + const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} }); + + try { + await sql.unsafe(` + DROP TABLE principal_permission_grants CASCADE; + DROP TABLE company_memberships CASCADE; + CREATE TABLE company_memberships ( + company_id uuid NOT NULL, + principal_type text NOT NULL, + principal_id text NOT NULL, + status text NOT NULL, + membership_role text + ); + CREATE TABLE principal_permission_grants ( + id uuid PRIMARY KEY, + company_id uuid NOT NULL, + principal_type text NOT NULL, + principal_id text NOT NULL, + permission_key text NOT NULL, + scope jsonb, + granted_by_user_id text + ); + INSERT INTO company_memberships VALUES + ('00000000-0000-4000-8000-000000000001', 'user', 'owner', 'active', 'owner'), + ('00000000-0000-4000-8000-000000000001', 'user', 'admin', 'active', 'admin'), + ('00000000-0000-4000-8000-000000000001', 'user', 'operator', 'active', 'member'); + INSERT INTO principal_permission_grants VALUES + ('00000000-0000-4000-8000-000000000010', '00000000-0000-4000-8000-000000000001', 'user', 'owner', 'users:manage_permissions', NULL, NULL), + ('00000000-0000-4000-8000-000000000011', '00000000-0000-4000-8000-000000000001', 'user', 'admin', 'tools:use', NULL, NULL), + ('00000000-0000-4000-8000-000000000012', '00000000-0000-4000-8000-000000000001', 'user', 'operator', 'tasks:assign', NULL, NULL), + ('00000000-0000-4000-8000-000000000013', '00000000-0000-4000-8000-000000000001', 'user', 'owner', 'tools:use', '{"projectId":"project-1"}', NULL), + ('00000000-0000-4000-8000-000000000014', '00000000-0000-4000-8000-000000000001', 'user', 'owner', 'tools:use', NULL, 'grant-author'), + ('00000000-0000-4000-8000-000000000015', '00000000-0000-4000-8000-000000000001', 'user', 'admin', 'users:manage_permissions', NULL, NULL), + ('00000000-0000-4000-8000-000000000016', '00000000-0000-4000-8000-000000000001', 'agent', 'owner', 'tools:use', NULL, NULL); + `); + + for (const statement of await migrationStatements()) { + await sql.unsafe(statement); + } + + const rows = await sql.unsafe>(` + SELECT id, grant_origin + FROM principal_permission_grants + ORDER BY id + `); + expect(rows).toEqual([ + { id: "00000000-0000-4000-8000-000000000010", grant_origin: "role_default" }, + { id: "00000000-0000-4000-8000-000000000011", grant_origin: "role_default" }, + { id: "00000000-0000-4000-8000-000000000012", grant_origin: "role_default" }, + { id: "00000000-0000-4000-8000-000000000013", grant_origin: "explicit" }, + { id: "00000000-0000-4000-8000-000000000014", grant_origin: "explicit" }, + { id: "00000000-0000-4000-8000-000000000015", grant_origin: "explicit" }, + { id: "00000000-0000-4000-8000-000000000016", grant_origin: "explicit" }, + ]); + } finally { + await sql.end(); + } + }, 20_000); +});