mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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 b3ccd15bca. 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>
This commit is contained in:
parent
dfe827c77f
commit
306f4e4c67
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[tenancyId,queryableId]` on the table `Permission` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Permission_tenancyId_queryableId_key" ON "Permission"("tenancyId", "queryableId");
|
||||
@ -211,7 +211,7 @@ model Permission {
|
||||
|
||||
description String?
|
||||
|
||||
// The scope of the permission. If projectConfigId is set, may be USER or TEAM; if teamId is set, must be TEAM.
|
||||
// The scope of the permission. If projectConfigId is set, may be PROJECT or TEAM; if teamId is set, must be PROJECT.
|
||||
scope PermissionScope
|
||||
|
||||
projectConfig ProjectConfig? @relation(fields: [projectConfigId], references: [id], onDelete: Cascade)
|
||||
@ -224,8 +224,9 @@ model Permission {
|
||||
|
||||
isDefaultTeamCreatorPermission Boolean @default(false)
|
||||
isDefaultTeamMemberPermission Boolean @default(false)
|
||||
isDefaultProjectPermission Boolean @default(false)
|
||||
isDefaultProjectPermission Boolean @default(false)
|
||||
|
||||
@@unique([tenancyId, queryableId])
|
||||
@@unique([projectConfigId, queryableId])
|
||||
@@unique([tenancyId, teamId, queryableId])
|
||||
}
|
||||
|
||||
@ -1,31 +1,47 @@
|
||||
import { createPermissionDefinition, deletePermissionDefinition, listPermissionDefinitions, updatePermissionDefinitions } from "@/lib/permissions";
|
||||
import { retryTransaction } from "@/prisma-client";
|
||||
import { createPermissionDefinition, deletePermissionDefinition, isErrorForNonUniquePermission, listPermissionDefinitions, updatePermissionDefinitions } from "@/lib/permissions";
|
||||
import { isPrismaUniqueConstraintViolation, retryTransaction } from "@/prisma-client";
|
||||
import { createCrudHandlers } from "@/route-handlers/crud-handler";
|
||||
import { KnownErrors } from "@stackframe/stack-shared";
|
||||
import { projectPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/project-permissions';
|
||||
import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
|
||||
|
||||
|
||||
export const projectPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(projectPermissionDefinitionsCrud, {
|
||||
paramsSchema: yupObject({
|
||||
permission_id: permissionDefinitionIdSchema.defined(),
|
||||
}),
|
||||
async onCreate({ auth, data }) {
|
||||
return await retryTransaction(async (tx) => {
|
||||
return await createPermissionDefinition(tx, {
|
||||
scope: "PROJECT",
|
||||
tenancy: auth.tenancy,
|
||||
data,
|
||||
});
|
||||
try {
|
||||
return await createPermissionDefinition(tx, {
|
||||
scope: "PROJECT",
|
||||
tenancy: auth.tenancy,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isErrorForNonUniquePermission(error)) {
|
||||
throw new KnownErrors.PermissionIdAlreadyExists(data.id);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
async onUpdate({ auth, data, params }) {
|
||||
return await retryTransaction(async (tx) => {
|
||||
return await updatePermissionDefinitions(tx, {
|
||||
scope: "PROJECT",
|
||||
tenancy: auth.tenancy,
|
||||
permissionId: params.permission_id,
|
||||
data,
|
||||
});
|
||||
try {
|
||||
return await updatePermissionDefinitions(tx, {
|
||||
scope: "PROJECT",
|
||||
tenancy: auth.tenancy,
|
||||
permissionId: params.permission_id,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isErrorForNonUniquePermission(error)) {
|
||||
throw new KnownErrors.PermissionIdAlreadyExists(data.id ?? '');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
async onDelete({ auth, params }) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { createPermissionDefinition, deletePermissionDefinition, listPermissionDefinitions, updatePermissionDefinitions } from "@/lib/permissions";
|
||||
import { createPermissionDefinition, deletePermissionDefinition, isErrorForNonUniquePermission, listPermissionDefinitions, updatePermissionDefinitions } from "@/lib/permissions";
|
||||
import { retryTransaction } from "@/prisma-client";
|
||||
import { createCrudHandlers } from "@/route-handlers/crud-handler";
|
||||
import { KnownErrors } from "@stackframe/stack-shared";
|
||||
import { teamPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions';
|
||||
import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
|
||||
@ -11,21 +12,35 @@ export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => creat
|
||||
}),
|
||||
async onCreate({ auth, data }) {
|
||||
return await retryTransaction(async (tx) => {
|
||||
return await createPermissionDefinition(tx, {
|
||||
scope: "TEAM",
|
||||
tenancy: auth.tenancy,
|
||||
data,
|
||||
});
|
||||
try {
|
||||
return await createPermissionDefinition(tx, {
|
||||
scope: "TEAM",
|
||||
tenancy: auth.tenancy,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isErrorForNonUniquePermission(error)) {
|
||||
throw new KnownErrors.PermissionIdAlreadyExists(data.id);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
async onUpdate({ auth, data, params }) {
|
||||
return await retryTransaction(async (tx) => {
|
||||
return await updatePermissionDefinitions(tx, {
|
||||
scope: "TEAM",
|
||||
tenancy: auth.tenancy,
|
||||
permissionId: params.permission_id,
|
||||
data,
|
||||
});
|
||||
try {
|
||||
return await updatePermissionDefinitions(tx, {
|
||||
scope: "TEAM",
|
||||
tenancy: auth.tenancy,
|
||||
permissionId: params.permission_id,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isErrorForNonUniquePermission(error)) {
|
||||
throw new KnownErrors.PermissionIdAlreadyExists(data.id ?? '');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
async onDelete({ auth, params }) {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { isPrismaUniqueConstraintViolation } from "@/prisma-client";
|
||||
import { TeamSystemPermission as DBTeamSystemPermission, Prisma } from "@prisma/client";
|
||||
import { KnownErrors } from "@stackframe/stack-shared";
|
||||
import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions";
|
||||
import { ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions";
|
||||
import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions";
|
||||
import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays";
|
||||
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { stringCompare, typedToLowercase, typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
@ -649,3 +650,9 @@ export async function grantDefaultProjectPermissions(
|
||||
|
||||
return defaultPermissions.length > 0;
|
||||
}
|
||||
|
||||
export function isErrorForNonUniquePermission(error: unknown): boolean {
|
||||
return isPrismaUniqueConstraintViolation(error, "Permission", ["tenancyId", "queryableId"]) ||
|
||||
isPrismaUniqueConstraintViolation(error, "Permission", ["projectConfigId", "queryableId"]) ||
|
||||
isPrismaUniqueConstraintViolation(error, "Permission", ["tenancyId", "teamId", "queryableId"]);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { withAccelerate } from "@prisma/extension-accelerate";
|
||||
import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
|
||||
import { filterUndefined, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
|
||||
import { deepPlainEquals, filterUndefined, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
|
||||
import { Result } from "@stackframe/stack-shared/dist/utils/results";
|
||||
import { traceSpan } from "./utils/telemetry";
|
||||
|
||||
@ -135,3 +135,21 @@ async function rawQueryArray<Q extends RawQuery<any>[]>(queries: Q): Promise<[]
|
||||
});
|
||||
}
|
||||
|
||||
// not exhaustive
|
||||
export const PRISMA_ERROR_CODES = {
|
||||
VALUE_TOO_LONG: "P2000",
|
||||
RECORD_NOT_FOUND: "P2001",
|
||||
UNIQUE_CONSTRAINT_VIOLATION: "P2002",
|
||||
FOREIGN_CONSTRAINT_VIOLATION: "P2003",
|
||||
GENERIC_CONSTRAINT_VIOLATION: "P2004",
|
||||
} as const;
|
||||
|
||||
export function isPrismaError(error: unknown, code: keyof typeof PRISMA_ERROR_CODES): error is Prisma.PrismaClientKnownRequestError {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === PRISMA_ERROR_CODES[code];
|
||||
}
|
||||
|
||||
export function isPrismaUniqueConstraintViolation(error: unknown, modelName: string, target: string | string[]): error is Prisma.PrismaClientKnownRequestError {
|
||||
if (!isPrismaError(error, "UNIQUE_CONSTRAINT_VIOLATION")) return false;
|
||||
if (!error.meta?.target) return false;
|
||||
return error.meta.modelName === modelName && deepPlainEquals(error.meta.target, target);
|
||||
}
|
||||
|
||||
@ -40,17 +40,18 @@ function CreateDialog(props: {
|
||||
onOpenChange: (open: boolean) => void,
|
||||
}) {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const permissions = stackAdminApp.useProjectPermissionDefinitions();
|
||||
const projectPermissions = stackAdminApp.useProjectPermissionDefinitions();
|
||||
const combinedPermissions = [...stackAdminApp.useTeamPermissionDefinitions(), ...projectPermissions];
|
||||
|
||||
const formSchema = yup.object({
|
||||
id: yup.string().defined()
|
||||
.notOneOf(permissions.map((p) => p.id), "ID already exists")
|
||||
.notOneOf(combinedPermissions.map((p) => p.id), "ID already exists")
|
||||
.matches(/^[a-z0-9_:]+$/, 'Only lowercase letters, numbers, ":" and "_" are allowed')
|
||||
.label("ID"),
|
||||
description: yup.string().label("Description"),
|
||||
containedPermissionIds: yup.array().of(yup.string().defined()).defined().default([]).meta({
|
||||
stackFormFieldRender: (props) => (
|
||||
<PermissionListField {...props} permissions={permissions} type="new" />
|
||||
<PermissionListField {...props} permissions={projectPermissions} type="new" />
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
@ -41,17 +41,18 @@ function CreateDialog(props: {
|
||||
onOpenChange: (open: boolean) => void,
|
||||
}) {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const permissions = stackAdminApp.useTeamPermissionDefinitions();
|
||||
const teamPermissions = stackAdminApp.useTeamPermissionDefinitions();
|
||||
const combinedPermissions = [...teamPermissions, ...stackAdminApp.useProjectPermissionDefinitions()];
|
||||
|
||||
const formSchema = yup.object({
|
||||
id: yup.string().defined()
|
||||
.notOneOf(permissions.map((p) => p.id), "ID already exists")
|
||||
.notOneOf(combinedPermissions.map((p) => p.id), "ID already exists")
|
||||
.matches(/^[a-z0-9_:]+$/, 'Only lowercase letters, numbers, ":" and "_" are allowed')
|
||||
.label("ID"),
|
||||
description: yup.string().label("Description"),
|
||||
containedPermissionIds: yup.array().of(yup.string().defined()).defined().default([]).meta({
|
||||
stackFormFieldRender: (props) => (
|
||||
<PermissionListField {...props} permissions={permissions} type="new" />
|
||||
<PermissionListField {...props} permissions={teamPermissions} type="new" />
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
@ -31,9 +31,11 @@ function EditDialog(props: {
|
||||
permissionType: PermissionType,
|
||||
}) {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const permissions = props.permissionType === 'project'
|
||||
? stackAdminApp.useProjectPermissionDefinitions()
|
||||
: stackAdminApp.useTeamPermissionDefinitions();
|
||||
const teamPermissions = stackAdminApp.useTeamPermissionDefinitions();
|
||||
const projectPermissions = stackAdminApp.useProjectPermissionDefinitions();
|
||||
const permissions = props.permissionType === 'project' ? projectPermissions : teamPermissions;
|
||||
const combinedPermissions = [...teamPermissions, ...projectPermissions];
|
||||
|
||||
const currentPermission = permissions.find((p) => p.id === props.selectedPermissionId);
|
||||
if (!currentPermission) {
|
||||
return null;
|
||||
@ -42,7 +44,7 @@ function EditDialog(props: {
|
||||
const formSchema = yup.object({
|
||||
id: yup.string()
|
||||
.defined()
|
||||
.notOneOf(permissions.map((p) => p.id).filter(p => p !== props.selectedPermissionId), "ID already exists")
|
||||
.notOneOf(combinedPermissions.map((p) => p.id).filter(p => p !== props.selectedPermissionId), "ID already exists")
|
||||
.matches(/^[a-z0-9_:]+$/, 'Only lowercase letters, numbers, ":" and "_" are allowed')
|
||||
.label("ID"),
|
||||
description: yup.string().label("Description"),
|
||||
|
||||
@ -173,3 +173,82 @@ it("creates, updates, and deletes a new user permission", async ({ expect }) =>
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("handles duplicate permission IDs correctly", async ({ expect }) => {
|
||||
backendContext.set({ projectKeys: InternalProjectKeys });
|
||||
const { adminAccessToken } = await Project.createAndGetAdminToken();
|
||||
|
||||
// Create first permission
|
||||
const response1 = await niceBackendFetch(`/api/v1/project-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: {
|
||||
id: 'duplicate_test',
|
||||
description: "Test permission"
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
expect(response1.status).toBe(201);
|
||||
|
||||
// Try to create another permission with the same ID
|
||||
const response2 = await niceBackendFetch(`/api/v1/project-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: {
|
||||
id: 'duplicate_test',
|
||||
description: "Another test permission"
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
expect(response2.status).toBe(400);
|
||||
expect(response2.body).toHaveProperty("code", "PERMISSION_ID_ALREADY_EXISTS");
|
||||
|
||||
// Create another permission
|
||||
const response3 = await niceBackendFetch(`/api/v1/project-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: {
|
||||
id: 'update_test',
|
||||
description: "Test permission for update"
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
expect(response3.status).toBe(201);
|
||||
|
||||
// Update the first permission to have the ID of the second (which should fail)
|
||||
const response4 = await niceBackendFetch(`/api/v1/project-permission-definitions/duplicate_test`, {
|
||||
accessType: "admin",
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: 'update_test',
|
||||
description: "Updated description"
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
expect(response4.status).toBe(400);
|
||||
expect(response4.body).toHaveProperty("code", "PERMISSION_ID_ALREADY_EXISTS");
|
||||
|
||||
// Clean up
|
||||
await niceBackendFetch(`/api/v1/project-permission-definitions/duplicate_test`, {
|
||||
accessType: "admin",
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
await niceBackendFetch(`/api/v1/project-permission-definitions/update_test`, {
|
||||
accessType: "admin",
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -315,3 +315,86 @@ it("should trigger project permission webhook when a permission is revoked from
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not be able to create a permission with the same name as an existing project permission", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const { adminAccessToken } = await Project.createAndGetAdminToken();
|
||||
|
||||
// First, create a project permission definition
|
||||
const createTeamPermissionResponse = await niceBackendFetch(`/api/v1/project-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: {
|
||||
id: 'custom_project_permission',
|
||||
description: 'A custom project permission',
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
|
||||
expect(createTeamPermissionResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 201,
|
||||
"body": {
|
||||
"contained_permission_ids": [],
|
||||
"description": "A custom project permission",
|
||||
"id": "custom_project_permission",
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
// Try creating another team permission with the same name
|
||||
const createAnotherTeamPermissionResponse = await niceBackendFetch(`/api/v1/team-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: { id: 'custom_project_permission' },
|
||||
headers: { 'x-stack-admin-access-token': adminAccessToken },
|
||||
});
|
||||
|
||||
expect(createAnotherTeamPermissionResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "PERMISSION_ID_ALREADY_EXISTS",
|
||||
"details": { "permission_id": "custom_project_permission" },
|
||||
"error": "Permission with ID \\"custom_project_permission\\" already exists. Choose a different ID.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "PERMISSION_ID_ALREADY_EXISTS",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
// Now try to create a project permission with the same name
|
||||
const createProjectPermissionResponse = await niceBackendFetch(`/api/v1/project-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: {
|
||||
id: 'custom_project_permission',
|
||||
description: 'Attempt to create a project permission with same name as team permission',
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: P2002 postgres codes should automatically be converted into duplicate key error
|
||||
expect(createProjectPermissionResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "PERMISSION_ID_ALREADY_EXISTS",
|
||||
"details": { "permission_id": "custom_project_permission" },
|
||||
"error": "Permission with ID \\"custom_project_permission\\" already exists. Choose a different ID.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "PERMISSION_ID_ALREADY_EXISTS",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@ -307,3 +307,82 @@ it("creates, updates, and delete a new team permission", async ({ expect }) => {
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("handles duplicate permission IDs correctly", async ({ expect }) => {
|
||||
backendContext.set({ projectKeys: InternalProjectKeys });
|
||||
const { adminAccessToken } = await Project.createAndGetAdminToken();
|
||||
|
||||
// Create first permission
|
||||
const response1 = await niceBackendFetch(`/api/v1/team-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: {
|
||||
id: 'duplicate_test',
|
||||
description: "Test permission"
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
expect(response1.status).toBe(201);
|
||||
|
||||
// Try to create another permission with the same ID
|
||||
const response2 = await niceBackendFetch(`/api/v1/team-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: {
|
||||
id: 'duplicate_test',
|
||||
description: "Another test permission"
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
expect(response2.status).toBe(400);
|
||||
expect(response2.body).toHaveProperty("code", "PERMISSION_ID_ALREADY_EXISTS");
|
||||
|
||||
// Create another permission
|
||||
const response3 = await niceBackendFetch(`/api/v1/team-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: {
|
||||
id: 'update_test',
|
||||
description: "Test permission for update"
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
expect(response3.status).toBe(201);
|
||||
|
||||
// Update the first permission to have the ID of the second (which should fail)
|
||||
const response4 = await niceBackendFetch(`/api/v1/team-permission-definitions/duplicate_test`, {
|
||||
accessType: "admin",
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: 'update_test',
|
||||
description: "Updated description"
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
expect(response4.status).toBe(400);
|
||||
expect(response4.body).toHaveProperty("code", "PERMISSION_ID_ALREADY_EXISTS");
|
||||
|
||||
// Clean up
|
||||
await niceBackendFetch(`/api/v1/team-permission-definitions/duplicate_test`, {
|
||||
accessType: "admin",
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
await niceBackendFetch(`/api/v1/team-permission-definitions/update_test`, {
|
||||
accessType: "admin",
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -300,3 +300,86 @@ it("should trigger team permission webhook when a permission is revoked from a u
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not be able to create a permission with the same name as an existing team permission", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const { adminAccessToken } = await Project.createAndGetAdminToken();
|
||||
|
||||
// First, create a team permission definition
|
||||
const createTeamPermissionResponse = await niceBackendFetch(`/api/v1/team-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: {
|
||||
id: 'custom_team_permission',
|
||||
description: 'A custom team permission',
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
|
||||
expect(createTeamPermissionResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 201,
|
||||
"body": {
|
||||
"contained_permission_ids": [],
|
||||
"description": "A custom team permission",
|
||||
"id": "custom_team_permission",
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
// Try creating another team permission with the same name
|
||||
const createAnotherTeamPermissionResponse = await niceBackendFetch(`/api/v1/team-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: { id: 'custom_team_permission' },
|
||||
headers: { 'x-stack-admin-access-token': adminAccessToken },
|
||||
});
|
||||
|
||||
expect(createAnotherTeamPermissionResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "PERMISSION_ID_ALREADY_EXISTS",
|
||||
"details": { "permission_id": "custom_team_permission" },
|
||||
"error": "Permission with ID \\"custom_team_permission\\" already exists. Choose a different ID.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "PERMISSION_ID_ALREADY_EXISTS",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
// Now try to create a project permission with the same name
|
||||
const createProjectPermissionResponse = await niceBackendFetch(`/api/v1/project-permission-definitions`, {
|
||||
accessType: "admin",
|
||||
method: "POST",
|
||||
body: {
|
||||
id: 'custom_team_permission',
|
||||
description: 'Attempt to create a project permission with same name as team permission',
|
||||
},
|
||||
headers: {
|
||||
'x-stack-admin-access-token': adminAccessToken
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: P2002 postgres codes should automatically be converted into duplicate key error
|
||||
expect(createProjectPermissionResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 400,
|
||||
"body": {
|
||||
"code": "PERMISSION_ID_ALREADY_EXISTS",
|
||||
"details": { "permission_id": "custom_team_permission" },
|
||||
"error": "Permission with ID \\"custom_team_permission\\" already exists. Choose a different ID.",
|
||||
},
|
||||
"headers": Headers {
|
||||
"x-stack-known-error": "PERMISSION_ID_ALREADY_EXISTS",
|
||||
<some fields may have been hidden>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@ -39,7 +39,8 @@ To check whether a user has a specific permission, use the `getPermission` metho
|
||||
|
||||
export function CheckUserPermission() {
|
||||
const user = useUser({ or: 'redirect' });
|
||||
const permission = user.usePermission('read');
|
||||
const team = user.useTeam('some-team-id');
|
||||
const permission = user.usePermission(team, 'read');
|
||||
|
||||
// Don't rely on client-side permission checks for business logic.
|
||||
return (
|
||||
@ -57,7 +58,8 @@ To check whether a user has a specific permission, use the `getPermission` metho
|
||||
|
||||
export default async function CheckUserPermission() {
|
||||
const user = await stackServerApp.getUser({ or: 'redirect' });
|
||||
const permission = await user.getPermission('read');
|
||||
const team = await stackServerApp.getTeam('some-team-id');
|
||||
const permission = await user.getPermission(team, 'read');
|
||||
|
||||
// This is a server-side check, so it's secure.
|
||||
return (
|
||||
|
||||
@ -1218,6 +1218,19 @@ const InvalidPollingCodeError = createKnownErrorConstructor(
|
||||
(json: any) => [json] as const,
|
||||
);
|
||||
|
||||
const PermissionIdAlreadyExists = createKnownErrorConstructor(
|
||||
KnownError,
|
||||
"PERMISSION_ID_ALREADY_EXISTS",
|
||||
(permissionId: string) => [
|
||||
400,
|
||||
`Permission with ID "${permissionId}" already exists. Choose a different ID.`,
|
||||
{
|
||||
permission_id: permissionId,
|
||||
},
|
||||
] as const,
|
||||
(json: any) => [json.permission_id] as const,
|
||||
);
|
||||
|
||||
export type KnownErrors = {
|
||||
[K in keyof typeof KnownErrors]: InstanceType<typeof KnownErrors[K]>;
|
||||
};
|
||||
@ -1229,6 +1242,7 @@ export const KnownErrors = {
|
||||
SchemaError,
|
||||
AllOverloadsFailed,
|
||||
ProjectAuthenticationError,
|
||||
PermissionIdAlreadyExists,
|
||||
InvalidProjectAuthentication,
|
||||
ProjectKeyWithoutAccessType,
|
||||
InvalidAccessType,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user