Skip to content

Commit 306f4e4

Browse files
TheCactusBlueN2D4fomalhautb
authored
Permission Robustness (#591)
<!-- ELLIPSIS_HIDDEN --> > [!IMPORTANT] > Enhance permission management by adding unique constraints, handling duplicate ID errors, and updating frontend and backend logic with comprehensive tests. > > - **Database**: > - Add unique constraint on `Permission` table for `[tenancyId, queryableId]` in `migration.sql`. > - Update `schema.prisma` to reflect new unique constraints. > - **Backend**: > - Update `crud.tsx` files to handle `PERMISSION_ID_ALREADY_EXISTS` error using `isErrorForNonUniquePermission()`. > - Add `isPrismaUniqueConstraintViolation()` in `prisma-client.tsx` to identify unique constraint violations. > - Add `PermissionIdAlreadyExists` error in `known-errors.tsx`. > - **Frontend**: > - Update `page-client.tsx` and `permission-table.tsx` to check for duplicate permission IDs before creation. > - **Tests**: > - Add tests in `project-permission-definitions.test.ts` and `team-permission-definitions.test.ts` to verify duplicate ID handling. > - Ensure permissions cannot be created with duplicate IDs across project and team contexts. > > <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup> for b3ccd15. It will automatically update as commits are pushed.</sup> <!-- ELLIPSIS_HIDDEN --> --------- Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com> Co-authored-by: Zai Shi <zaishi00@outlook.com>
1 parent dfe827c commit 306f4e4

15 files changed

Lines changed: 450 additions & 41 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/*
2+
Warnings:
3+
4+
- A unique constraint covering the columns `[tenancyId,queryableId]` on the table `Permission` will be added. If there are existing duplicate values, this will fail.
5+
6+
*/
7+
-- CreateIndex
8+
CREATE UNIQUE INDEX "Permission_tenancyId_queryableId_key" ON "Permission"("tenancyId", "queryableId");

apps/backend/prisma/schema.prisma

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ model Permission {
211211
212212
description String?
213213
214-
// The scope of the permission. If projectConfigId is set, may be USER or TEAM; if teamId is set, must be TEAM.
214+
// The scope of the permission. If projectConfigId is set, may be PROJECT or TEAM; if teamId is set, must be PROJECT.
215215
scope PermissionScope
216216
217217
projectConfig ProjectConfig? @relation(fields: [projectConfigId], references: [id], onDelete: Cascade)
@@ -224,8 +224,9 @@ model Permission {
224224
225225
isDefaultTeamCreatorPermission Boolean @default(false)
226226
isDefaultTeamMemberPermission Boolean @default(false)
227-
isDefaultProjectPermission Boolean @default(false)
227+
isDefaultProjectPermission Boolean @default(false)
228228
229+
@@unique([tenancyId, queryableId])
229230
@@unique([projectConfigId, queryableId])
230231
@@unique([tenancyId, teamId, queryableId])
231232
}

apps/backend/src/app/api/latest/project-permission-definitions/crud.tsx

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,47 @@
1-
import { createPermissionDefinition, deletePermissionDefinition, listPermissionDefinitions, updatePermissionDefinitions } from "@/lib/permissions";
2-
import { retryTransaction } from "@/prisma-client";
1+
import { createPermissionDefinition, deletePermissionDefinition, isErrorForNonUniquePermission, listPermissionDefinitions, updatePermissionDefinitions } from "@/lib/permissions";
2+
import { isPrismaUniqueConstraintViolation, retryTransaction } from "@/prisma-client";
33
import { createCrudHandlers } from "@/route-handlers/crud-handler";
4+
import { KnownErrors } from "@stackframe/stack-shared";
45
import { projectPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/project-permissions';
56
import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
67
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
78

9+
810
export const projectPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(projectPermissionDefinitionsCrud, {
911
paramsSchema: yupObject({
1012
permission_id: permissionDefinitionIdSchema.defined(),
1113
}),
1214
async onCreate({ auth, data }) {
1315
return await retryTransaction(async (tx) => {
14-
return await createPermissionDefinition(tx, {
15-
scope: "PROJECT",
16-
tenancy: auth.tenancy,
17-
data,
18-
});
16+
try {
17+
return await createPermissionDefinition(tx, {
18+
scope: "PROJECT",
19+
tenancy: auth.tenancy,
20+
data,
21+
});
22+
} catch (error) {
23+
if (isErrorForNonUniquePermission(error)) {
24+
throw new KnownErrors.PermissionIdAlreadyExists(data.id);
25+
}
26+
throw error;
27+
}
1928
});
2029
},
2130
async onUpdate({ auth, data, params }) {
2231
return await retryTransaction(async (tx) => {
23-
return await updatePermissionDefinitions(tx, {
24-
scope: "PROJECT",
25-
tenancy: auth.tenancy,
26-
permissionId: params.permission_id,
27-
data,
28-
});
32+
try {
33+
return await updatePermissionDefinitions(tx, {
34+
scope: "PROJECT",
35+
tenancy: auth.tenancy,
36+
permissionId: params.permission_id,
37+
data,
38+
});
39+
} catch (error) {
40+
if (isErrorForNonUniquePermission(error)) {
41+
throw new KnownErrors.PermissionIdAlreadyExists(data.id ?? '');
42+
}
43+
throw error;
44+
}
2945
});
3046
},
3147
async onDelete({ auth, params }) {

apps/backend/src/app/api/latest/team-permission-definitions/crud.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { createPermissionDefinition, deletePermissionDefinition, listPermissionDefinitions, updatePermissionDefinitions } from "@/lib/permissions";
1+
import { createPermissionDefinition, deletePermissionDefinition, isErrorForNonUniquePermission, listPermissionDefinitions, updatePermissionDefinitions } from "@/lib/permissions";
22
import { retryTransaction } from "@/prisma-client";
33
import { createCrudHandlers } from "@/route-handlers/crud-handler";
4+
import { KnownErrors } from "@stackframe/stack-shared";
45
import { teamPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions';
56
import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
67
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
@@ -11,21 +12,35 @@ export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => creat
1112
}),
1213
async onCreate({ auth, data }) {
1314
return await retryTransaction(async (tx) => {
14-
return await createPermissionDefinition(tx, {
15-
scope: "TEAM",
16-
tenancy: auth.tenancy,
17-
data,
18-
});
15+
try {
16+
return await createPermissionDefinition(tx, {
17+
scope: "TEAM",
18+
tenancy: auth.tenancy,
19+
data,
20+
});
21+
} catch (error) {
22+
if (isErrorForNonUniquePermission(error)) {
23+
throw new KnownErrors.PermissionIdAlreadyExists(data.id);
24+
}
25+
throw error;
26+
}
1927
});
2028
},
2129
async onUpdate({ auth, data, params }) {
2230
return await retryTransaction(async (tx) => {
23-
return await updatePermissionDefinitions(tx, {
24-
scope: "TEAM",
25-
tenancy: auth.tenancy,
26-
permissionId: params.permission_id,
27-
data,
28-
});
31+
try {
32+
return await updatePermissionDefinitions(tx, {
33+
scope: "TEAM",
34+
tenancy: auth.tenancy,
35+
permissionId: params.permission_id,
36+
data,
37+
});
38+
} catch (error) {
39+
if (isErrorForNonUniquePermission(error)) {
40+
throw new KnownErrors.PermissionIdAlreadyExists(data.id ?? '');
41+
}
42+
throw error;
43+
}
2944
});
3045
},
3146
async onDelete({ auth, params }) {

apps/backend/src/lib/permissions.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { isPrismaUniqueConstraintViolation } from "@/prisma-client";
12
import { TeamSystemPermission as DBTeamSystemPermission, Prisma } from "@prisma/client";
23
import { KnownErrors } from "@stackframe/stack-shared";
3-
import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions";
44
import { ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions";
5+
import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions";
56
import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays";
67
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
78
import { stringCompare, typedToLowercase, typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
@@ -649,3 +650,9 @@ export async function grantDefaultProjectPermissions(
649650

650651
return defaultPermissions.length > 0;
651652
}
653+
654+
export function isErrorForNonUniquePermission(error: unknown): boolean {
655+
return isPrismaUniqueConstraintViolation(error, "Permission", ["tenancyId", "queryableId"]) ||
656+
isPrismaUniqueConstraintViolation(error, "Permission", ["projectConfigId", "queryableId"]) ||
657+
isPrismaUniqueConstraintViolation(error, "Permission", ["tenancyId", "teamId", "queryableId"]);
658+
}

apps/backend/src/prisma-client.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Prisma, PrismaClient } from "@prisma/client";
22
import { withAccelerate } from "@prisma/extension-accelerate";
33
import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
4-
import { filterUndefined, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
4+
import { deepPlainEquals, filterUndefined, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
55
import { Result } from "@stackframe/stack-shared/dist/utils/results";
66
import { traceSpan } from "./utils/telemetry";
77

@@ -135,3 +135,21 @@ async function rawQueryArray<Q extends RawQuery<any>[]>(queries: Q): Promise<[]
135135
});
136136
}
137137

138+
// not exhaustive
139+
export const PRISMA_ERROR_CODES = {
140+
VALUE_TOO_LONG: "P2000",
141+
RECORD_NOT_FOUND: "P2001",
142+
UNIQUE_CONSTRAINT_VIOLATION: "P2002",
143+
FOREIGN_CONSTRAINT_VIOLATION: "P2003",
144+
GENERIC_CONSTRAINT_VIOLATION: "P2004",
145+
} as const;
146+
147+
export function isPrismaError(error: unknown, code: keyof typeof PRISMA_ERROR_CODES): error is Prisma.PrismaClientKnownRequestError {
148+
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === PRISMA_ERROR_CODES[code];
149+
}
150+
151+
export function isPrismaUniqueConstraintViolation(error: unknown, modelName: string, target: string | string[]): error is Prisma.PrismaClientKnownRequestError {
152+
if (!isPrismaError(error, "UNIQUE_CONSTRAINT_VIOLATION")) return false;
153+
if (!error.meta?.target) return false;
154+
return error.meta.modelName === modelName && deepPlainEquals(error.meta.target, target);
155+
}

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-permissions/page-client.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,18 @@ function CreateDialog(props: {
4040
onOpenChange: (open: boolean) => void,
4141
}) {
4242
const stackAdminApp = useAdminApp();
43-
const permissions = stackAdminApp.useProjectPermissionDefinitions();
43+
const projectPermissions = stackAdminApp.useProjectPermissionDefinitions();
44+
const combinedPermissions = [...stackAdminApp.useTeamPermissionDefinitions(), ...projectPermissions];
4445

4546
const formSchema = yup.object({
4647
id: yup.string().defined()
47-
.notOneOf(permissions.map((p) => p.id), "ID already exists")
48+
.notOneOf(combinedPermissions.map((p) => p.id), "ID already exists")
4849
.matches(/^[a-z0-9_:]+$/, 'Only lowercase letters, numbers, ":" and "_" are allowed')
4950
.label("ID"),
5051
description: yup.string().label("Description"),
5152
containedPermissionIds: yup.array().of(yup.string().defined()).defined().default([]).meta({
5253
stackFormFieldRender: (props) => (
53-
<PermissionListField {...props} permissions={permissions} type="new" />
54+
<PermissionListField {...props} permissions={projectPermissions} type="new" />
5455
),
5556
}),
5657
});

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-permissions/page-client.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,18 @@ function CreateDialog(props: {
4141
onOpenChange: (open: boolean) => void,
4242
}) {
4343
const stackAdminApp = useAdminApp();
44-
const permissions = stackAdminApp.useTeamPermissionDefinitions();
44+
const teamPermissions = stackAdminApp.useTeamPermissionDefinitions();
45+
const combinedPermissions = [...teamPermissions, ...stackAdminApp.useProjectPermissionDefinitions()];
4546

4647
const formSchema = yup.object({
4748
id: yup.string().defined()
48-
.notOneOf(permissions.map((p) => p.id), "ID already exists")
49+
.notOneOf(combinedPermissions.map((p) => p.id), "ID already exists")
4950
.matches(/^[a-z0-9_:]+$/, 'Only lowercase letters, numbers, ":" and "_" are allowed')
5051
.label("ID"),
5152
description: yup.string().label("Description"),
5253
containedPermissionIds: yup.array().of(yup.string().defined()).defined().default([]).meta({
5354
stackFormFieldRender: (props) => (
54-
<PermissionListField {...props} permissions={permissions} type="new" />
55+
<PermissionListField {...props} permissions={teamPermissions} type="new" />
5556
),
5657
}),
5758
});

apps/dashboard/src/components/data-table/permission-table.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ function EditDialog(props: {
3131
permissionType: PermissionType,
3232
}) {
3333
const stackAdminApp = useAdminApp();
34-
const permissions = props.permissionType === 'project'
35-
? stackAdminApp.useProjectPermissionDefinitions()
36-
: stackAdminApp.useTeamPermissionDefinitions();
34+
const teamPermissions = stackAdminApp.useTeamPermissionDefinitions();
35+
const projectPermissions = stackAdminApp.useProjectPermissionDefinitions();
36+
const permissions = props.permissionType === 'project' ? projectPermissions : teamPermissions;
37+
const combinedPermissions = [...teamPermissions, ...projectPermissions];
38+
3739
const currentPermission = permissions.find((p) => p.id === props.selectedPermissionId);
3840
if (!currentPermission) {
3941
return null;
@@ -42,7 +44,7 @@ function EditDialog(props: {
4244
const formSchema = yup.object({
4345
id: yup.string()
4446
.defined()
45-
.notOneOf(permissions.map((p) => p.id).filter(p => p !== props.selectedPermissionId), "ID already exists")
47+
.notOneOf(combinedPermissions.map((p) => p.id).filter(p => p !== props.selectedPermissionId), "ID already exists")
4648
.matches(/^[a-z0-9_:]+$/, 'Only lowercase letters, numbers, ":" and "_" are allowed')
4749
.label("ID"),
4850
description: yup.string().label("Description"),

apps/e2e/tests/backend/endpoints/api/v1/project-permission-definitions.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,82 @@ it("creates, updates, and deletes a new user permission", async ({ expect }) =>
173173
}
174174
`);
175175
});
176+
177+
it("handles duplicate permission IDs correctly", async ({ expect }) => {
178+
backendContext.set({ projectKeys: InternalProjectKeys });
179+
const { adminAccessToken } = await Project.createAndGetAdminToken();
180+
181+
// Create first permission
182+
const response1 = await niceBackendFetch(`/api/v1/project-permission-definitions`, {
183+
accessType: "admin",
184+
method: "POST",
185+
body: {
186+
id: 'duplicate_test',
187+
description: "Test permission"
188+
},
189+
headers: {
190+
'x-stack-admin-access-token': adminAccessToken
191+
},
192+
});
193+
expect(response1.status).toBe(201);
194+
195+
// Try to create another permission with the same ID
196+
const response2 = await niceBackendFetch(`/api/v1/project-permission-definitions`, {
197+
accessType: "admin",
198+
method: "POST",
199+
body: {
200+
id: 'duplicate_test',
201+
description: "Another test permission"
202+
},
203+
headers: {
204+
'x-stack-admin-access-token': adminAccessToken
205+
},
206+
});
207+
expect(response2.status).toBe(400);
208+
expect(response2.body).toHaveProperty("code", "PERMISSION_ID_ALREADY_EXISTS");
209+
210+
// Create another permission
211+
const response3 = await niceBackendFetch(`/api/v1/project-permission-definitions`, {
212+
accessType: "admin",
213+
method: "POST",
214+
body: {
215+
id: 'update_test',
216+
description: "Test permission for update"
217+
},
218+
headers: {
219+
'x-stack-admin-access-token': adminAccessToken
220+
},
221+
});
222+
expect(response3.status).toBe(201);
223+
224+
// Update the first permission to have the ID of the second (which should fail)
225+
const response4 = await niceBackendFetch(`/api/v1/project-permission-definitions/duplicate_test`, {
226+
accessType: "admin",
227+
method: "PATCH",
228+
body: {
229+
id: 'update_test',
230+
description: "Updated description"
231+
},
232+
headers: {
233+
'x-stack-admin-access-token': adminAccessToken
234+
},
235+
});
236+
expect(response4.status).toBe(400);
237+
expect(response4.body).toHaveProperty("code", "PERMISSION_ID_ALREADY_EXISTS");
238+
239+
// Clean up
240+
await niceBackendFetch(`/api/v1/project-permission-definitions/duplicate_test`, {
241+
accessType: "admin",
242+
method: "DELETE",
243+
headers: {
244+
'x-stack-admin-access-token': adminAccessToken
245+
},
246+
});
247+
await niceBackendFetch(`/api/v1/project-permission-definitions/update_test`, {
248+
accessType: "admin",
249+
method: "DELETE",
250+
headers: {
251+
'x-stack-admin-access-token': adminAccessToken
252+
},
253+
});
254+
});

0 commit comments

Comments
 (0)