fix(auth): preserve explicit grants on demotion

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
cryppadotta 2026-09-10 20:06:43 +00:00
parent cc5694449f
commit 3fff8a959f
7 changed files with 47905 additions and 24 deletions

View File

@ -0,0 +1,2 @@
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'));

File diff suppressed because it is too large Load Diff

View File

@ -1933,6 +1933,13 @@
"when": 1789258667522,
"tag": "0277_omniscient_darwin",
"breakpoints": true
},
{
"idx": 278,
"version": "7",
"when": 1789258722176,
"tag": "0278_natural_nehzno",
"breakpoints": true
}
]
}

View File

@ -1,4 +1,14 @@
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import {
check,
index,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const principalPermissionGrants = pgTable(
@ -10,6 +20,10 @@ export const principalPermissionGrants = pgTable(
principalId: text("principal_id").notNull(),
permissionKey: text("permission_key").notNull(),
scope: jsonb("scope").$type<Record<string, unknown> | null>(),
grantOrigin: text("grant_origin")
.notNull()
.default("explicit")
.$type<"explicit" | "role_default">(),
grantedByUserId: text("granted_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
@ -25,5 +39,9 @@ export const principalPermissionGrants = pgTable(
table.companyId,
table.permissionKey,
),
grantOriginCheck: check(
"principal_permission_grants_origin_check",
sql`${table.grantOrigin} in ('explicit', 'role_default')`,
),
}),
);

View File

@ -20,6 +20,7 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { ownerHasRequiredGrant } from "../security/board-key-owner-authority.js";
import { accessService } from "../services/access.js";
import { grantsForHumanRole } from "../services/company-member-roles.js";
vi.hoisted(() => {
@ -129,7 +130,7 @@ describeEmbeddedPostgres("access routes permissions upgrade compatibility", () =
expect(unchanged.membershipRole).toBe("owner");
}, 10_000);
it("retires former role defaults but keeps custom grants when the role-only route demotes a member", async () => {
it("retires former role defaults but keeps explicit grants when the role-only route demotes a member", async () => {
const { company, owner } = await createCompanyWithOwner(db);
const member = await db
.insert(companyMemberships)
@ -142,27 +143,42 @@ describeEmbeddedPostgres("access routes permissions upgrade compatibility", () =
})
.returning()
.then((rows) => rows[0]!);
const access = accessService(db);
await access.ensureRoleDefaultGrants(
company.id,
member.principalId,
"admin",
owner.principalId,
);
const customScope = { projectIds: ["project-1"] };
await db.insert(principalPermissionGrants).values([
...grantsForHumanRole("admin")
.filter((grant) => grant.permissionKey !== "tools:use")
.map((grant) => ({
companyId: company.id,
principalType: "user" as const,
principalId: member.principalId,
permissionKey: grant.permissionKey,
scope: grant.scope,
grantedByUserId: owner.principalId,
})),
{
companyId: company.id,
principalType: "user" as const,
principalId: member.principalId,
permissionKey: "tools:use" as const,
scope: customScope,
await db
.update(principalPermissionGrants)
.set({ grantOrigin: "explicit", grantedByUserId: owner.principalId })
.where(and(
eq(principalPermissionGrants.companyId, company.id),
eq(principalPermissionGrants.principalId, member.principalId),
eq(principalPermissionGrants.permissionKey, "tools:use"),
));
await db
.update(principalPermissionGrants)
.set({
grantOrigin: "explicit",
grantedByUserId: owner.principalId,
},
]);
scope: customScope,
})
.where(and(
eq(principalPermissionGrants.companyId, company.id),
eq(principalPermissionGrants.principalId, member.principalId),
eq(principalPermissionGrants.permissionKey, "tools:manage_runtime"),
));
const grantsBeforeDemotion = await db
.select()
.from(principalPermissionGrants)
.where(eq(principalPermissionGrants.principalId, member.principalId));
expect(grantsBeforeDemotion.filter((grant) => grant.grantOrigin === "role_default"))
.toHaveLength(grantsForHumanRole("admin").length - 2);
expect(grantsBeforeDemotion.find((grant) => grant.permissionKey === "tools:use"))
.toEqual(expect.objectContaining({ grantOrigin: "explicit", scope: null }));
await expect(ownerHasRequiredGrant(
db,
@ -188,7 +204,7 @@ describeEmbeddedPostgres("access routes permissions upgrade compatibility", () =
eq(principalPermissionGrants.principalId, member.principalId),
),
);
expect(grants).toHaveLength(2);
expect(grants).toHaveLength(3);
expect(grants).toEqual(expect.arrayContaining([
expect.objectContaining({
permissionKey: "tasks:assign",
@ -196,7 +212,14 @@ describeEmbeddedPostgres("access routes permissions upgrade compatibility", () =
}),
expect.objectContaining({
permissionKey: "tools:use",
scope: null,
grantOrigin: "explicit",
grantedByUserId: owner.principalId,
}),
expect.objectContaining({
permissionKey: "tools:manage_runtime",
scope: customScope,
grantOrigin: "explicit",
grantedByUserId: owner.principalId,
}),
]));

View File

@ -1,4 +1,4 @@
import { and, eq, inArray, isNull, ne, notInArray, sql } from "drizzle-orm";
import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
companyMemberships,
@ -1031,6 +1031,7 @@ export function accessService(db: Db) {
.update(principalPermissionGrants)
.set({
scope,
grantOrigin: "explicit",
grantedByUserId,
updatedAt: new Date(),
})
@ -1137,7 +1138,7 @@ export function accessService(db: Db) {
eq(principalPermissionGrants.principalType, "user"),
eq(principalPermissionGrants.principalId, existing.principalId),
inArray(principalPermissionGrants.permissionKey, retiredDefaultKeys),
isNull(principalPermissionGrants.scope),
eq(principalPermissionGrants.grantOrigin, "role_default"),
));
}
}

View File

@ -22,6 +22,7 @@ export async function insertMissingPrincipalGrants(
principalId: string;
grants: GrantInput[];
grantedByUserId: string | null;
grantOrigin?: "explicit" | "role_default";
},
): Promise<number> {
if (input.grants.length === 0) return 0;
@ -36,6 +37,7 @@ export async function insertMissingPrincipalGrants(
principalId: input.principalId,
permissionKey: grant.permissionKey,
scope: grant.scope ?? null,
grantOrigin: input.grantOrigin ?? "explicit",
grantedByUserId: input.grantedByUserId,
createdAt: now,
updatedAt: now,
@ -70,6 +72,7 @@ export async function ensureHumanRoleDefaultGrants(
principalId: input.principalId,
grants: grantsForHumanRole(role),
grantedByUserId: input.grantedByUserId,
grantOrigin: "role_default",
});
}