fix(auth): preserve explicit grants on demotion
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
cc5694449f
commit
3fff8a959f
|
|
@ -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
|
|
@ -1933,6 +1933,13 @@
|
|||
"when": 1789258667522,
|
||||
"tag": "0277_omniscient_darwin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 278,
|
||||
"version": "7",
|
||||
"when": 1789258722176,
|
||||
"tag": "0278_natural_nehzno",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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')`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
]));
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue