From 793272c8c557cf67a38b27b94d242162d306737f Mon Sep 17 00:00:00 2001 From: CactusBlue Date: Thu, 27 Mar 2025 09:39:48 -0700 Subject: [PATCH] Rename USER to PROJECT in permissions (#576) > [!IMPORTANT] > Renamed user-related permissions to project-related permissions across the codebase, affecting enums, schemas, APIs, models, and tests. > > - **Behavior**: > - Renamed `USER` to `PROJECT` in `PermissionScope` enum in `schema.prisma` and `migration.sql`. > - Updated `isDefaultUserPermission` to `isDefaultProjectPermission` in `schema.prisma` and `migration.sql`. > - Removed `jwks.json/route.ts` file. > - **API Changes**: > - Renamed `user-permission-definitions` and `user-permissions` endpoints to `project-permission-definitions` and `project-permissions` in `route.tsx` files. > - Updated CRUD handlers in `crud.tsx` files to reflect new naming. > - **Models**: > - Updated models in `permissions.tsx` to use `ProjectPermission` and `AdminProjectPermission`. > - Updated `KnownErrors` to use `ProjectPermissionRequired`. > - **Tests**: > - Renamed test files and updated test cases in `e2e/tests/backend/endpoints/api/v1` to reflect new naming. > - **Misc**: > - Updated `admin-app-impl.ts`, `client-app-impl.ts`, and `server-app-impl.ts` to use new project permission naming. > - Updated `schema-fields.ts` to reflect new permission ID schema. > > This description was created by [Ellipsis](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral) for 08924f5241a18878cc3e59b1e06802f7ab38c813. It will automatically update as commits are pushed. --------- Co-authored-by: Konsti Wohlwend --- .../migration.sql | 17 ++- apps/backend/prisma/schema.prisma | 4 +- .../src/app/.well-known/jwks.json/route.ts | 32 ----- .../[permission_id]/route.tsx | 4 + .../crud.tsx | 14 +-- .../project-permission-definitions/route.tsx | 4 + .../[user_id]/[permission_id]/route.tsx | 4 + .../crud.tsx | 28 ++--- .../api/latest/project-permissions/route.tsx | 3 + .../app/api/latest/projects/current/crud.tsx | 14 +-- .../team-permission-definitions/crud.tsx | 4 +- .../app/api/latest/team-permissions/crud.tsx | 6 +- .../[permission_id]/route.tsx | 4 - .../user-permission-definitions/route.tsx | 4 - .../[user_id]/[permission_id]/route.tsx | 4 - .../app/api/latest/user-permissions/route.tsx | 3 - .../backend/src/app/api/latest/users/crud.tsx | 4 +- apps/backend/src/lib/permissions.tsx | 36 +++--- apps/backend/src/lib/projects.tsx | 4 +- apps/backend/src/lib/request-checks.tsx | 8 +- apps/backend/src/lib/webhooks.tsx | 6 +- .../page-client.tsx | 10 +- .../page.tsx | 0 .../projects/[projectId]/sidebar-layout.tsx | 6 +- .../data-table/permission-table.tsx | 29 +++-- ...=> project-permission-definitions.test.ts} | 14 +-- ...ns.test.ts => project-permissions.test.ts} | 50 ++++---- .../src/interface/adminInterface.ts | 20 +-- .../src/interface/clientInterface.ts | 16 +++ .../src/interface/crud/project-permissions.ts | 118 ++++++++++++++++++ .../src/interface/crud/team-permissions.ts | 8 +- .../src/interface/crud/user-permissions.ts | 118 ------------------ .../src/interface/serverInterface.ts | 20 +++ packages/stack-shared/src/known-errors.tsx | 6 +- packages/stack-shared/src/schema-fields.ts | 6 +- .../apps/implementations/admin-app-impl.ts | 38 +++--- .../apps/implementations/client-app-impl.ts | 83 +++++++++--- .../apps/implementations/server-app-impl.ts | 82 +++++++++--- .../stack-app/apps/interfaces/admin-app.ts | 10 +- packages/template/src/lib/stack-app/index.ts | 8 +- .../src/lib/stack-app/permissions/index.ts | 16 +-- .../template/src/lib/stack-app/users/index.ts | 32 +++++ 42 files changed, 518 insertions(+), 379 deletions(-) rename apps/backend/prisma/migrations/{20250324204959_project_user_permissions => 20250325235813_project_user_permissions}/migration.sql (62%) delete mode 100644 apps/backend/src/app/.well-known/jwks.json/route.ts create mode 100644 apps/backend/src/app/api/latest/project-permission-definitions/[permission_id]/route.tsx rename apps/backend/src/app/api/latest/{user-permission-definitions => project-permission-definitions}/crud.tsx (69%) create mode 100644 apps/backend/src/app/api/latest/project-permission-definitions/route.tsx create mode 100644 apps/backend/src/app/api/latest/project-permissions/[user_id]/[permission_id]/route.tsx rename apps/backend/src/app/api/latest/{user-permissions => project-permissions}/crud.tsx (67%) create mode 100644 apps/backend/src/app/api/latest/project-permissions/route.tsx delete mode 100644 apps/backend/src/app/api/latest/user-permission-definitions/[permission_id]/route.tsx delete mode 100644 apps/backend/src/app/api/latest/user-permission-definitions/route.tsx delete mode 100644 apps/backend/src/app/api/latest/user-permissions/[user_id]/[permission_id]/route.tsx delete mode 100644 apps/backend/src/app/api/latest/user-permissions/route.tsx rename apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/{user-permissions => project-permissions}/page-client.tsx (88%) rename apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/{user-permissions => project-permissions}/page.tsx (100%) rename apps/e2e/tests/backend/endpoints/api/v1/{user-permission-definitions.test.ts => project-permission-definitions.test.ts} (86%) rename apps/e2e/tests/backend/endpoints/api/v1/{user-permissions.test.ts => project-permissions.test.ts} (79%) create mode 100644 packages/stack-shared/src/interface/crud/project-permissions.ts delete mode 100644 packages/stack-shared/src/interface/crud/user-permissions.ts diff --git a/apps/backend/prisma/migrations/20250324204959_project_user_permissions/migration.sql b/apps/backend/prisma/migrations/20250325235813_project_user_permissions/migration.sql similarity index 62% rename from apps/backend/prisma/migrations/20250324204959_project_user_permissions/migration.sql rename to apps/backend/prisma/migrations/20250325235813_project_user_permissions/migration.sql index 7579eefd0..0ea8f373c 100644 --- a/apps/backend/prisma/migrations/20250324204959_project_user_permissions/migration.sql +++ b/apps/backend/prisma/migrations/20250325235813_project_user_permissions/migration.sql @@ -1,5 +1,20 @@ +/* + Warnings: + + - The values [USER] on the enum `PermissionScope` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "PermissionScope_new" AS ENUM ('PROJECT', 'TEAM'); +ALTER TABLE "Permission" ALTER COLUMN "scope" TYPE "PermissionScope_new" USING ("scope"::text::"PermissionScope_new"); +ALTER TYPE "PermissionScope" RENAME TO "PermissionScope_old"; +ALTER TYPE "PermissionScope_new" RENAME TO "PermissionScope"; +DROP TYPE "PermissionScope_old"; +COMMIT; + -- AlterTable -ALTER TABLE "Permission" ADD COLUMN "isDefaultUserPermission" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Permission" ADD COLUMN "isDefaultProjectPermission" BOOLEAN NOT NULL DEFAULT false; -- CreateTable CREATE TABLE "ProjectUserDirectPermission" ( diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 8b51e1947..7b39afb02 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -224,14 +224,14 @@ model Permission { isDefaultTeamCreatorPermission Boolean @default(false) isDefaultTeamMemberPermission Boolean @default(false) - isDefaultUserPermission Boolean @default(false) + isDefaultProjectPermission Boolean @default(false) @@unique([projectConfigId, queryableId]) @@unique([tenancyId, teamId, queryableId]) } enum PermissionScope { - USER + PROJECT TEAM } diff --git a/apps/backend/src/app/.well-known/jwks.json/route.ts b/apps/backend/src/app/.well-known/jwks.json/route.ts deleted file mode 100644 index 4776e9051..000000000 --- a/apps/backend/src/app/.well-known/jwks.json/route.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; -import { getPublicJwkSet } from "@stackframe/stack-shared/dist/utils/jwt"; -import { createSmartRouteHandler } from "../../../route-handlers/smart-route-handler"; - -export const GET = createSmartRouteHandler({ - metadata: { - summary: "JWKS Endpoint", - description: "Returns information about the JSON Web Key Set (JWKS) used to sign and verify JWTs.", - tags: [], - hidden: true, - }, - request: yupObject({}), - response: yupObject({ - statusCode: yupNumber().oneOf([200]).defined(), - bodyType: yupString().oneOf(["json"]).defined(), - body: yupObject({ - keys: yupArray().defined(), - message: yupString().optional(), - }).defined(), - }), - async handler() { - return { - statusCode: 200, - bodyType: "json", - body: { - ...await getPublicJwkSet(getEnvVariable("STACK_SERVER_SECRET")), - message: "This is deprecated. Please disable the legacy JWT signing in the tenancy setting page, and move to /api/v1/projects//.well-known/jwks.json", - } - }; - }, -}); diff --git a/apps/backend/src/app/api/latest/project-permission-definitions/[permission_id]/route.tsx b/apps/backend/src/app/api/latest/project-permission-definitions/[permission_id]/route.tsx new file mode 100644 index 000000000..52902a823 --- /dev/null +++ b/apps/backend/src/app/api/latest/project-permission-definitions/[permission_id]/route.tsx @@ -0,0 +1,4 @@ +import { projectPermissionDefinitionsCrudHandlers } from "../crud"; + +export const PATCH = projectPermissionDefinitionsCrudHandlers.updateHandler; +export const DELETE = projectPermissionDefinitionsCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/latest/user-permission-definitions/crud.tsx b/apps/backend/src/app/api/latest/project-permission-definitions/crud.tsx similarity index 69% rename from apps/backend/src/app/api/latest/user-permission-definitions/crud.tsx rename to apps/backend/src/app/api/latest/project-permission-definitions/crud.tsx index e545270cc..dce118326 100644 --- a/apps/backend/src/app/api/latest/user-permission-definitions/crud.tsx +++ b/apps/backend/src/app/api/latest/project-permission-definitions/crud.tsx @@ -1,18 +1,18 @@ import { createPermissionDefinition, deletePermissionDefinition, listPermissionDefinitions, updatePermissionDefinitions } from "@/lib/permissions"; import { retryTransaction } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; -import { userPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/user-permissions'; -import { teamPermissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields"; +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 userPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(userPermissionDefinitionsCrud, { +export const projectPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(projectPermissionDefinitionsCrud, { paramsSchema: yupObject({ - permission_id: teamPermissionDefinitionIdSchema.defined(), + permission_id: permissionDefinitionIdSchema.defined(), }), async onCreate({ auth, data }) { return await retryTransaction(async (tx) => { return await createPermissionDefinition(tx, { - scope: "USER", + scope: "PROJECT", tenancy: auth.tenancy, data, }); @@ -21,7 +21,7 @@ export const userPermissionDefinitionsCrudHandlers = createLazyProxy(() => creat async onUpdate({ auth, data, params }) { return await retryTransaction(async (tx) => { return await updatePermissionDefinitions(tx, { - scope: "USER", + scope: "PROJECT", tenancy: auth.tenancy, permissionId: params.permission_id, data, @@ -39,7 +39,7 @@ export const userPermissionDefinitionsCrudHandlers = createLazyProxy(() => creat async onList({ auth }) { return await retryTransaction(async (tx) => { return { - items: await listPermissionDefinitions(tx, "USER", auth.tenancy), + items: await listPermissionDefinitions(tx, "PROJECT", auth.tenancy), is_paginated: false, }; }); diff --git a/apps/backend/src/app/api/latest/project-permission-definitions/route.tsx b/apps/backend/src/app/api/latest/project-permission-definitions/route.tsx new file mode 100644 index 000000000..e4c2c2996 --- /dev/null +++ b/apps/backend/src/app/api/latest/project-permission-definitions/route.tsx @@ -0,0 +1,4 @@ +import { projectPermissionDefinitionsCrudHandlers } from "./crud"; + +export const POST = projectPermissionDefinitionsCrudHandlers.createHandler; +export const GET = projectPermissionDefinitionsCrudHandlers.listHandler; diff --git a/apps/backend/src/app/api/latest/project-permissions/[user_id]/[permission_id]/route.tsx b/apps/backend/src/app/api/latest/project-permissions/[user_id]/[permission_id]/route.tsx new file mode 100644 index 000000000..be85633df --- /dev/null +++ b/apps/backend/src/app/api/latest/project-permissions/[user_id]/[permission_id]/route.tsx @@ -0,0 +1,4 @@ +import { projectPermissionsCrudHandlers } from "../../crud"; + +export const POST = projectPermissionsCrudHandlers.createHandler; +export const DELETE = projectPermissionsCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/latest/user-permissions/crud.tsx b/apps/backend/src/app/api/latest/project-permissions/crud.tsx similarity index 67% rename from apps/backend/src/app/api/latest/user-permissions/crud.tsx rename to apps/backend/src/app/api/latest/project-permissions/crud.tsx index c7219acc7..515006652 100644 --- a/apps/backend/src/app/api/latest/user-permissions/crud.tsx +++ b/apps/backend/src/app/api/latest/project-permissions/crud.tsx @@ -1,37 +1,37 @@ -import { grantUserPermission, listUserPermissions, revokeUserPermission } from "@/lib/permissions"; -import { ensureUserExists, ensureUserPermissionExists } from "@/lib/request-checks"; -import { sendUserPermissionCreatedWebhook, sendUserPermissionDeletedWebhook } from "@/lib/webhooks"; +import { grantProjectPermission, listProjectPermissions, revokeProjectPermission } from "@/lib/permissions"; +import { ensureUserExists, ensureProjectPermissionExists } from "@/lib/request-checks"; +import { sendProjectPermissionCreatedWebhook, sendProjectPermissionDeletedWebhook } from "@/lib/webhooks"; import { retryTransaction } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; import { KnownErrors } from "@stackframe/stack-shared"; -import { userPermissionsCrud } from '@stackframe/stack-shared/dist/interface/crud/user-permissions'; -import { teamPermissionDefinitionIdSchema, userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { projectPermissionsCrud } from '@stackframe/stack-shared/dist/interface/crud/project-permissions'; +import { permissionDefinitionIdSchema, userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; -export const userPermissionsCrudHandlers = createLazyProxy(() => createCrudHandlers(userPermissionsCrud, { +export const projectPermissionsCrudHandlers = createLazyProxy(() => createCrudHandlers(projectPermissionsCrud, { querySchema: yupObject({ user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: 'Filter with the user ID. If set, only the permissions this user has will be returned. Client request must set `user_id=me`', exampleValue: 'me' } }), - permission_id: teamPermissionDefinitionIdSchema.optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: 'Filter with the permission ID. If set, only the permissions with this specific ID will be returned', exampleValue: '16399452-c4f3-4554-8e44-c2d67bb60360' } }), + permission_id: permissionDefinitionIdSchema.optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: 'Filter with the permission ID. If set, only the permissions with this specific ID will be returned', exampleValue: '16399452-c4f3-4554-8e44-c2d67bb60360' } }), recursive: yupString().oneOf(['true', 'false']).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: 'Whether to list permissions recursively. If set to `false`, only the permission the users directly have will be listed. If set to `true` all the direct and indirect permissions will be listed.', exampleValue: 'true' } }), }), paramsSchema: yupObject({ user_id: userIdOrMeSchema.defined(), - permission_id: teamPermissionDefinitionIdSchema.defined(), + permission_id: permissionDefinitionIdSchema.defined(), }), async onCreate({ auth, params }) { const result = await retryTransaction(async (tx) => { await ensureUserExists(tx, { tenancyId: auth.tenancy.id, userId: params.user_id }); - return await grantUserPermission(tx, { + return await grantProjectPermission(tx, { tenancy: auth.tenancy, userId: params.user_id, permissionId: params.permission_id }); }); - runAsynchronouslyAndWaitUntil(sendUserPermissionCreatedWebhook({ + runAsynchronouslyAndWaitUntil(sendProjectPermissionCreatedWebhook({ projectId: auth.project.id, data: { id: params.permission_id, @@ -43,7 +43,7 @@ export const userPermissionsCrudHandlers = createLazyProxy(() => createCrudHandl }, async onDelete({ auth, params }) { const result = await retryTransaction(async (tx) => { - await ensureUserPermissionExists(tx, { + await ensureProjectPermissionExists(tx, { tenancy: auth.tenancy, userId: params.user_id, permissionId: params.permission_id, @@ -51,14 +51,14 @@ export const userPermissionsCrudHandlers = createLazyProxy(() => createCrudHandl recursive: false, }); - return await revokeUserPermission(tx, { + return await revokeProjectPermission(tx, { tenancy: auth.tenancy, userId: params.user_id, permissionId: params.permission_id }); }); - runAsynchronouslyAndWaitUntil(sendUserPermissionDeletedWebhook({ + runAsynchronouslyAndWaitUntil(sendProjectPermissionDeletedWebhook({ projectId: auth.project.id, data: { id: params.permission_id, @@ -79,7 +79,7 @@ export const userPermissionsCrudHandlers = createLazyProxy(() => createCrudHandl return await retryTransaction(async (tx) => { return { - items: await listUserPermissions(tx, { + items: await listProjectPermissions(tx, { tenancy: auth.tenancy, permissionId: query.permission_id, userId: query.user_id, diff --git a/apps/backend/src/app/api/latest/project-permissions/route.tsx b/apps/backend/src/app/api/latest/project-permissions/route.tsx new file mode 100644 index 000000000..7ce3c4ab3 --- /dev/null +++ b/apps/backend/src/app/api/latest/project-permissions/route.tsx @@ -0,0 +1,3 @@ +import { projectPermissionsCrudHandlers } from "./crud"; + +export const GET = projectPermissionsCrudHandlers.listHandler; diff --git a/apps/backend/src/app/api/latest/projects/current/crud.tsx b/apps/backend/src/app/api/latest/projects/current/crud.tsx index c8fa99ebb..eb80405fd 100644 --- a/apps/backend/src/app/api/latest/projects/current/crud.tsx +++ b/apps/backend/src/app/api/latest/projects/current/crud.tsx @@ -34,27 +34,27 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro ] as const; const teamPermissions = await listPermissionDefinitions(tx, "TEAM", auth.tenancy); - const userPermissions = await listPermissionDefinitions(tx, "USER", auth.tenancy); + const projectPermissions = await listPermissionDefinitions(tx, "PROJECT", auth.tenancy); // Handle user default permissions const userDefaultPerms = data.config?.user_default_permissions?.map((p) => p.id); if (userDefaultPerms) { - if (!userDefaultPerms.every((id) => userPermissions.some((perm) => perm.id === id))) { + if (!userDefaultPerms.every((id) => projectPermissions.some((perm) => perm.id === id))) { throw new StatusError(StatusError.BadRequest, - `Invalid user default permission ids: ${userDefaultPerms.filter(id => !userPermissions.some(perm => perm.id === id)).join(', ')}`); + `Invalid user default permission ids: ${userDefaultPerms.filter(id => !projectPermissions.some(perm => perm.id === id)).join(', ')}`); } - // Remove existing default user permissions + // Remove existing default project permissions await tx.permission.updateMany({ where: { projectConfigId: oldProject.config.id, }, data: { - isDefaultUserPermission: false, + isDefaultProjectPermission: false, }, }); - // Add new default user permissions + // Add new default project permissions await tx.permission.updateMany({ where: { projectConfigId: oldProject.config.id, @@ -63,7 +63,7 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro }, }, data: { - isDefaultUserPermission: true, + isDefaultProjectPermission: true, }, }); } diff --git a/apps/backend/src/app/api/latest/team-permission-definitions/crud.tsx b/apps/backend/src/app/api/latest/team-permission-definitions/crud.tsx index 7bb43fe9b..c32f8f9db 100644 --- a/apps/backend/src/app/api/latest/team-permission-definitions/crud.tsx +++ b/apps/backend/src/app/api/latest/team-permission-definitions/crud.tsx @@ -2,12 +2,12 @@ import { createPermissionDefinition, deletePermissionDefinition, listPermissionD import { retryTransaction } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { teamPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions'; -import { teamPermissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields"; +import { permissionDefinitionIdSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamPermissionDefinitionsCrud, { paramsSchema: yupObject({ - permission_id: teamPermissionDefinitionIdSchema.defined(), + permission_id: permissionDefinitionIdSchema.defined(), }), async onCreate({ auth, data }) { return await retryTransaction(async (tx) => { diff --git a/apps/backend/src/app/api/latest/team-permissions/crud.tsx b/apps/backend/src/app/api/latest/team-permissions/crud.tsx index 15fe8ee4c..c96e36773 100644 --- a/apps/backend/src/app/api/latest/team-permissions/crud.tsx +++ b/apps/backend/src/app/api/latest/team-permissions/crud.tsx @@ -6,7 +6,7 @@ import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; import { KnownErrors } from "@stackframe/stack-shared"; import { teamPermissionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions'; -import { teamPermissionDefinitionIdSchema, userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { permissionDefinitionIdSchema, userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; @@ -14,13 +14,13 @@ export const teamPermissionsCrudHandlers = createLazyProxy(() => createCrudHandl querySchema: yupObject({ team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: 'Filter with the team ID. If set, only the permissions of the members in a specific team will be returned.', exampleValue: 'cce084a3-28b7-418e-913e-c8ee6d802ea4' } }), user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: 'Filter with the user ID. If set, only the permissions this user has will be returned. Client request must set `user_id=me`', exampleValue: 'me' } }), - permission_id: teamPermissionDefinitionIdSchema.optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: 'Filter with the permission ID. If set, only the permissions with this specific ID will be returned', exampleValue: '16399452-c4f3-4554-8e44-c2d67bb60360' } }), + permission_id: permissionDefinitionIdSchema.optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: 'Filter with the permission ID. If set, only the permissions with this specific ID will be returned', exampleValue: '16399452-c4f3-4554-8e44-c2d67bb60360' } }), recursive: yupString().oneOf(['true', 'false']).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: 'Whether to list permissions recursively. If set to `false`, only the permission the users directly have will be listed. If set to `true` all the direct and indirect permissions will be listed.', exampleValue: 'true' } }), }), paramsSchema: yupObject({ team_id: yupString().uuid().defined(), user_id: userIdOrMeSchema.defined(), - permission_id: teamPermissionDefinitionIdSchema.defined(), + permission_id: permissionDefinitionIdSchema.defined(), }), async onCreate({ auth, params }) { const result = await retryTransaction(async (tx) => { diff --git a/apps/backend/src/app/api/latest/user-permission-definitions/[permission_id]/route.tsx b/apps/backend/src/app/api/latest/user-permission-definitions/[permission_id]/route.tsx deleted file mode 100644 index a25252a69..000000000 --- a/apps/backend/src/app/api/latest/user-permission-definitions/[permission_id]/route.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { userPermissionDefinitionsCrudHandlers } from "../crud"; - -export const PATCH = userPermissionDefinitionsCrudHandlers.updateHandler; -export const DELETE = userPermissionDefinitionsCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/latest/user-permission-definitions/route.tsx b/apps/backend/src/app/api/latest/user-permission-definitions/route.tsx deleted file mode 100644 index 3a5d328cc..000000000 --- a/apps/backend/src/app/api/latest/user-permission-definitions/route.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { userPermissionDefinitionsCrudHandlers } from "./crud"; - -export const POST = userPermissionDefinitionsCrudHandlers.createHandler; -export const GET = userPermissionDefinitionsCrudHandlers.listHandler; diff --git a/apps/backend/src/app/api/latest/user-permissions/[user_id]/[permission_id]/route.tsx b/apps/backend/src/app/api/latest/user-permissions/[user_id]/[permission_id]/route.tsx deleted file mode 100644 index 7e57ae857..000000000 --- a/apps/backend/src/app/api/latest/user-permissions/[user_id]/[permission_id]/route.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { userPermissionsCrudHandlers } from "../../crud"; - -export const POST = userPermissionsCrudHandlers.createHandler; -export const DELETE = userPermissionsCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/latest/user-permissions/route.tsx b/apps/backend/src/app/api/latest/user-permissions/route.tsx deleted file mode 100644 index b8a525185..000000000 --- a/apps/backend/src/app/api/latest/user-permissions/route.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { userPermissionsCrudHandlers } from "./crud"; - -export const GET = userPermissionsCrudHandlers.listHandler; diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 7ed211546..028dd2286 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -1,5 +1,5 @@ +import { grantDefaultProjectPermissions } from "@/lib/permissions"; import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks"; -import { grantDefaultUserPermissions } from "@/lib/permissions"; import { getSoleTenancyFromProject, getTenancy } from "@/lib/tenancies"; import { PrismaTransaction } from "@/lib/types"; import { sendTeamMembershipDeletedWebhook, sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks"; @@ -712,7 +712,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC } // Grant default user permissions - await grantDefaultUserPermissions(tx, { + await grantDefaultProjectPermissions(tx, { tenancy: auth.tenancy, userId: newUser.projectUserId }); diff --git a/apps/backend/src/lib/permissions.tsx b/apps/backend/src/lib/permissions.tsx index b9601f2b9..969b0648a 100644 --- a/apps/backend/src/lib/permissions.tsx +++ b/apps/backend/src/lib/permissions.tsx @@ -1,7 +1,7 @@ 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 { UserPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/user-permissions"; +import { ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-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"; @@ -42,7 +42,7 @@ type ExtendedTeamPermissionDefinition = TeamPermissionDefinitionsCrud["Admin"][" __database_id: string, __is_default_team_member_permission?: boolean, __is_default_team_creator_permission?: boolean, - __is_default_user_permission?: boolean, + __is_default_project_permission?: boolean, }; export function teamPermissionDefinitionJsonFromDbType(db: Prisma.PermissionGetPayload<{ include: typeof fullPermissionInclude }>): ExtendedTeamPermissionDefinition { @@ -54,13 +54,13 @@ export function teamPermissionDefinitionJsonFromDbType(db: Prisma.PermissionGetP export function teamPermissionDefinitionJsonFromRawDbType(db: any | Prisma.PermissionGetPayload<{ include: typeof fullPermissionInclude }>): ExtendedTeamPermissionDefinition { if (!db.projectConfigId && !db.teamId) throw new StackAssertionError(`Permission DB object should have either projectConfigId or teamId`, { db }); if (db.projectConfigId && db.teamId) throw new StackAssertionError(`Permission DB object should have either projectConfigId or teamId, not both`, { db }); - if (db.scope === "USER" && db.teamId) throw new StackAssertionError(`Permission DB object should not have teamId when scope is USER`, { db }); + if (db.scope === "PROJECT" && db.teamId) throw new StackAssertionError(`Permission DB object should not have teamId when scope is PROJECT`, { db }); return { __database_id: db.dbId, __is_default_team_member_permission: db.isDefaultTeamMemberPermission, __is_default_team_creator_permission: db.isDefaultTeamCreatorPermission, - __is_default_user_permission: db.isDefaultUserPermission, + __is_default_project_permission: db.isDefaultProjectPermission, id: db.queryableId, description: db.description || undefined, contained_permission_ids: db.parentEdges?.map((edge: any) => { @@ -78,7 +78,7 @@ export function teamPermissionDefinitionJsonFromRawDbType(db: any | Prisma.Permi export function teamPermissionDefinitionJsonFromTeamSystemDbType(db: DBTeamSystemPermission, projectConfig: { teamCreateDefaultSystemPermissions: string[] | null, teamMemberDefaultSystemPermissions: string[] | null, - userDefaultPermissions?: string[] | null, + projectDefaultPermissions?: string[] | null, }): ExtendedTeamPermissionDefinition { if ((["teamMemberDefaultSystemPermissions", "teamCreateDefaultSystemPermissions"] as const).some(key => projectConfig[key] !== null && !Array.isArray(projectConfig[key]))) { throw new StackAssertionError(`Project config should have (nullable) array values for teamMemberDefaultSystemPermissions and teamCreateDefaultSystemPermissions`, { projectConfig }); @@ -88,7 +88,7 @@ export function teamPermissionDefinitionJsonFromTeamSystemDbType(db: DBTeamSyste __database_id: '$' + typedToLowercase(db), __is_default_team_member_permission: projectConfig.teamMemberDefaultSystemPermissions?.includes(db) ?? false, __is_default_team_creator_permission: projectConfig.teamCreateDefaultSystemPermissions?.includes(db) ?? false, - __is_default_user_permission: projectConfig.userDefaultPermissions?.includes(db) ?? false, + __is_default_project_permission: projectConfig.projectDefaultPermissions?.includes(db) ?? false, id: '$' + typedToLowercase(db), description: descriptionMap[db], contained_permission_ids: [] as string[], @@ -99,7 +99,7 @@ async function getParentDbIds( tx: PrismaTransaction, options: { tenancy: Tenancy, - scope: "TEAM" | "USER", + scope: "TEAM" | "PROJECT", containedPermissionIds?: string[], } ) { @@ -324,7 +324,7 @@ export async function revokeTeamPermission( export async function listPermissionDefinitions( tx: PrismaTransaction, - scope: "TEAM" | "USER", + scope: "TEAM" | "PROJECT", tenancy: Tenancy ): Promise<(TeamPermissionDefinitionsCrud["Admin"]["Read"] & { __database_id: string })[]> { const projectConfig = await tx.projectConfig.findUnique({ @@ -356,7 +356,7 @@ export async function listPermissionDefinitions( export async function createPermissionDefinition( tx: PrismaTransaction, options: { - scope: "TEAM" | "USER", + scope: "TEAM" | "PROJECT", tenancy: Tenancy, data: { id: string, @@ -402,7 +402,7 @@ export async function createPermissionDefinition( export async function updatePermissionDefinitions( tx: PrismaTransaction, options: { - scope: "TEAM" | "USER", + scope: "TEAM" | "PROJECT", tenancy: Tenancy, permissionId: string, data: { @@ -477,7 +477,7 @@ export async function deletePermissionDefinition( // User permission functions -export async function listUserPermissions( +export async function listProjectPermissions( tx: PrismaTransaction, options: { tenancy: Tenancy, @@ -485,8 +485,8 @@ export async function listUserPermissions( permissionId?: string, recursive: boolean, } -): Promise { - const permissionDefs = await listPermissionDefinitions(tx, "USER", options.tenancy); +): Promise { + const permissionDefs = await listPermissionDefinitions(tx, "PROJECT", options.tenancy); const permissionsMap = new Map(permissionDefs.map(p => [p.id, p])); const results = await tx.projectUserDirectPermission.findMany({ where: { @@ -528,7 +528,7 @@ export async function listUserPermissions( .filter(p => options.permissionId ? p.id === options.permissionId : true); } -export async function grantUserPermission( +export async function grantProjectPermission( tx: PrismaTransaction, options: { tenancy: Tenancy, @@ -579,7 +579,7 @@ export async function grantUserPermission( }; } -export async function revokeUserPermission( +export async function revokeProjectPermission( tx: PrismaTransaction, options: { tenancy: Tenancy, @@ -610,10 +610,10 @@ export async function revokeUserPermission( } /** - * Grants default user permissions to a user + * Grants default project permissions to a user * This function should be called when a new user is created */ -export async function grantDefaultUserPermissions( +export async function grantDefaultProjectPermissions( tx: PrismaTransaction, options: { tenancy: Tenancy, @@ -623,7 +623,7 @@ export async function grantDefaultUserPermissions( const defaultPermissions = await tx.permission.findMany({ where: { projectConfigId: options.tenancy.config.id, - isDefaultUserPermission: true, + isDefaultProjectPermission: true, } }); diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index d42e96149..db8abd3ff 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -173,7 +173,7 @@ export function projectPrismaToCrud( .concat(prisma.config.teamMemberDefaultSystemPermissions.map(db => teamPermissionDefinitionJsonFromTeamSystemDbType(db, prisma.config))) .sort((a, b) => stringCompare(a.id, b.id)) .map(perm => ({ id: perm.id })), - user_default_permissions: prisma.config.permissions.filter(perm => perm.isDefaultUserPermission) + user_default_permissions: prisma.config.permissions.filter(perm => perm.isDefaultProjectPermission) .map(teamPermissionDefinitionJsonFromDbType) .sort((a, b) => stringCompare(a.id, b.id)) .map(perm => ({ id: perm.id })), @@ -464,7 +464,7 @@ export function getProjectQuery(projectId: string): RawQuery perm.__is_default_team_member_permission) .map(perm => ({ id: perm.id })), user_default_permissions: teamPermissions - .filter(perm => perm.__is_default_user_permission) + .filter(perm => perm.__is_default_project_permission) .map(perm => ({ id: perm.id })), }, }; diff --git a/apps/backend/src/lib/request-checks.tsx b/apps/backend/src/lib/request-checks.tsx index 44249a797..a34a2f85f 100644 --- a/apps/backend/src/lib/request-checks.tsx +++ b/apps/backend/src/lib/request-checks.tsx @@ -3,7 +3,7 @@ import { KnownErrors } from "@stackframe/stack-shared"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { ProviderType, sharedProviders, standardProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; -import { listUserPermissions, listUserTeamPermissions } from "./permissions"; +import { listProjectPermissions, listUserTeamPermissions } from "./permissions"; import { Tenancy } from "./tenancies"; import { PrismaTransaction } from "./types"; @@ -113,7 +113,7 @@ export async function ensureUserTeamPermissionExists( } } -export async function ensureUserPermissionExists( +export async function ensureProjectPermissionExists( tx: PrismaTransaction, options: { tenancy: Tenancy, @@ -128,7 +128,7 @@ export async function ensureUserPermissionExists( userId: options.userId, }); - const result = await listUserPermissions(tx, { + const result = await listProjectPermissions(tx, { tenancy: options.tenancy, userId: options.userId, permissionId: options.permissionId, @@ -139,7 +139,7 @@ export async function ensureUserPermissionExists( if (options.errorType === 'not-exist') { throw new KnownErrors.PermissionNotFound(options.permissionId); } else { - throw new KnownErrors.UserPermissionRequired(options.userId, options.permissionId); + throw new KnownErrors.ProjectPermissionRequired(options.userId, options.permissionId); } } } diff --git a/apps/backend/src/lib/webhooks.tsx b/apps/backend/src/lib/webhooks.tsx index 93bfd2e60..cac56608e 100644 --- a/apps/backend/src/lib/webhooks.tsx +++ b/apps/backend/src/lib/webhooks.tsx @@ -1,8 +1,8 @@ +import { projectPermissionCreatedWebhookEvent, projectPermissionDeletedWebhookEvent } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; import { teamMembershipCreatedWebhookEvent, teamMembershipDeletedWebhookEvent } from "@stackframe/stack-shared/dist/interface/crud/team-memberships"; import { teamPermissionCreatedWebhookEvent, teamPermissionDeletedWebhookEvent } from "@stackframe/stack-shared/dist/interface/crud/team-permissions"; import { teamCreatedWebhookEvent, teamDeletedWebhookEvent, teamUpdatedWebhookEvent } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { userCreatedWebhookEvent, userDeletedWebhookEvent, userUpdatedWebhookEvent } from "@stackframe/stack-shared/dist/interface/crud/users"; -import { userPermissionCreatedWebhookEvent, userPermissionDeletedWebhookEvent } from "@stackframe/stack-shared/dist/interface/crud/user-permissions"; import { WebhookEvent } from "@stackframe/stack-shared/dist/interface/webhooks"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -74,5 +74,5 @@ export const sendTeamMembershipCreatedWebhook = createWebhookSender(teamMembersh export const sendTeamMembershipDeletedWebhook = createWebhookSender(teamMembershipDeletedWebhookEvent); export const sendTeamPermissionCreatedWebhook = createWebhookSender(teamPermissionCreatedWebhookEvent); export const sendTeamPermissionDeletedWebhook = createWebhookSender(teamPermissionDeletedWebhookEvent); -export const sendUserPermissionCreatedWebhook = createWebhookSender(userPermissionCreatedWebhookEvent); -export const sendUserPermissionDeletedWebhook = createWebhookSender(userPermissionDeletedWebhookEvent); +export const sendProjectPermissionCreatedWebhook = createWebhookSender(projectPermissionCreatedWebhookEvent); +export const sendProjectPermissionDeletedWebhook = createWebhookSender(projectPermissionDeletedWebhookEvent); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/user-permissions/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-permissions/page-client.tsx similarity index 88% rename from apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/user-permissions/page-client.tsx rename to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-permissions/page-client.tsx index 38cfba47c..ecabf029e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/user-permissions/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-permissions/page-client.tsx @@ -10,12 +10,12 @@ import { useAdminApp } from "../use-admin-app"; export default function PageClient() { const stackAdminApp = useAdminApp(); - const permissions = stackAdminApp.useUserPermissionDefinitions(); + const permissions = stackAdminApp.useProjectPermissionDefinitions(); const [createPermissionModalOpen, setCreatePermissionModalOpen] = React.useState(false); return ( setCreatePermissionModalOpen(true)}> Create Permission @@ -24,7 +24,7 @@ export default function PageClient() { void, }) { const stackAdminApp = useAdminApp(); - const permissions = stackAdminApp.useUserPermissionDefinitions(); + const permissions = stackAdminApp.useProjectPermissionDefinitions(); const formSchema = yup.object({ id: yup.string().defined() @@ -62,7 +62,7 @@ function CreateDialog(props: { formSchema={formSchema} okButton={{ label: "Create" }} onSubmit={async (values) => { - await stackAdminApp.createUserPermissionDefinition({ + await stackAdminApp.createProjectPermissionDefinition({ id: values.id, description: values.description, containedPermissionIds: values.containedPermissionIds, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/user-permissions/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-permissions/page.tsx similarity index 100% rename from apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/user-permissions/page.tsx rename to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-permissions/page.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 0a3489415..34e1af4ae 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -112,9 +112,9 @@ const navigationItems: (Label | Item | Hidden)[] = [ type: 'item' }, { - name: "User Permissions", - href: "/user-permissions", - regex: /^\/projects\/[^\/]+\/user-permissions$/, + name: "Project Permissions", + href: "/project-permissions", + regex: /^\/projects\/[^\/]+\/project-permissions$/, icon: LockKeyhole, type: 'item' }, diff --git a/apps/dashboard/src/components/data-table/permission-table.tsx b/apps/dashboard/src/components/data-table/permission-table.tsx index c55a41568..459cbc250 100644 --- a/apps/dashboard/src/components/data-table/permission-table.tsx +++ b/apps/dashboard/src/components/data-table/permission-table.tsx @@ -14,7 +14,7 @@ type AdminPermissionDefinition = { containedPermissionIds: string[], }; -type PermissionType = 'user' | 'team'; +type PermissionType = 'project' | 'team'; function toolbarRender(table: Table) { return ( @@ -31,8 +31,8 @@ function EditDialog(props: { permissionType: PermissionType, }) { const stackAdminApp = useAdminApp(); - const permissions = props.permissionType === 'user' - ? stackAdminApp.useUserPermissionDefinitions() + const permissions = props.permissionType === 'project' + ? stackAdminApp.useProjectPermissionDefinitions() : stackAdminApp.useTeamPermissionDefinitions(); const currentPermission = permissions.find((p) => p.id === props.selectedPermissionId); if (!currentPermission) { @@ -62,10 +62,6 @@ function EditDialog(props: { }) }).default(currentPermission); - const updatePermission = props.permissionType === 'user' - ? stackAdminApp.updateUserPermissionDefinition - : stackAdminApp.updateTeamPermissionDefinition; - return { runAsynchronously(async () => { - await updatePermission(props.selectedPermissionId, values); + if (props.permissionType === 'project') { + await stackAdminApp.updateProjectPermissionDefinition(props.selectedPermissionId, values); + } else { + await stackAdminApp.updateTeamPermissionDefinition(props.selectedPermissionId, values); + } }); }} cancelButton @@ -87,10 +87,7 @@ function DeleteDialog(props: { onOpenChange: (open: boolean) => void, permissionType: PermissionType, }) { - const stackApp = useAdminApp(); - const deletePermission = props.permissionType === 'user' - ? stackApp.deleteUserPermissionDefinition - : stackApp.deleteTeamPermissionDefinition; + const stackAdminApp = useAdminApp(); return (props: { title="Delete Permission" danger cancelButton - okButton={{ label: "Delete Permission", onClick: async () => { await deletePermission(props.permission.id); } }} + okButton={{ label: "Delete Permission", onClick: async () => { + if (props.permissionType === 'project') { + await stackAdminApp.deleteProjectPermissionDefinition(props.permission.id); + } else { + await stackAdminApp.deleteTeamPermissionDefinition(props.permission.id); + } + } }} confirmText="I understand this will remove the permission from all users and other permissions that contain it." > {`Are you sure you want to delete the permission "${props.permission.id}"?`} diff --git a/apps/e2e/tests/backend/endpoints/api/v1/user-permission-definitions.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/project-permission-definitions.test.ts similarity index 86% rename from apps/e2e/tests/backend/endpoints/api/v1/user-permission-definitions.test.ts rename to apps/e2e/tests/backend/endpoints/api/v1/project-permission-definitions.test.ts index 8bbb228af..c5fe0f7f8 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/user-permission-definitions.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/project-permission-definitions.test.ts @@ -6,7 +6,7 @@ it("lists all the user permissions", async ({ expect }) => { backendContext.set({ projectKeys: InternalProjectKeys }); const { adminAccessToken } = await Project.createAndGetAdminToken(); - const response = await niceBackendFetch(`/api/v1/user-permission-definitions`, { + const response = await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "GET", headers: { @@ -29,7 +29,7 @@ it("creates, updates, and deletes a new user permission", async ({ expect }) => backendContext.set({ projectKeys: InternalProjectKeys }); const { adminAccessToken } = await Project.createAndGetAdminToken(); - const response1 = await niceBackendFetch(`/api/v1/user-permission-definitions`, { + const response1 = await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "POST", body: { @@ -51,7 +51,7 @@ it("creates, updates, and deletes a new user permission", async ({ expect }) => `); // create another permission with contained permissions - const response2 = await niceBackendFetch(`/api/v1/user-permission-definitions`, { + const response2 = await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "POST", body: { @@ -74,7 +74,7 @@ it("creates, updates, and deletes a new user permission", async ({ expect }) => `); // test recursive case - const response3 = await niceBackendFetch(`/api/v1/user-permission-definitions`, { + const response3 = await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "POST", body: { @@ -98,7 +98,7 @@ it("creates, updates, and deletes a new user permission", async ({ expect }) => `); // list all permissions again - const response4 = await niceBackendFetch(`/api/v1/user-permission-definitions`, { + const response4 = await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "GET", headers: { @@ -130,7 +130,7 @@ it("creates, updates, and deletes a new user permission", async ({ expect }) => `); // delete the permission - const response5 = await niceBackendFetch(`/api/v1/user-permission-definitions/p1`, { + const response5 = await niceBackendFetch(`/api/v1/project-permission-definitions/p1`, { accessType: "admin", method: "DELETE", headers: { @@ -146,7 +146,7 @@ it("creates, updates, and deletes a new user permission", async ({ expect }) => `); // list all permissions again - const response6 = await niceBackendFetch(`/api/v1/user-permission-definitions`, { + const response6 = await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "GET", headers: { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/user-permissions.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/project-permissions.test.ts similarity index 79% rename from apps/e2e/tests/backend/endpoints/api/v1/user-permissions.test.ts rename to apps/e2e/tests/backend/endpoints/api/v1/project-permissions.test.ts index abf593d49..e1df627b8 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/user-permissions.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/project-permissions.test.ts @@ -1,11 +1,11 @@ import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { it } from "../../../../helpers"; -import { ApiKey, Auth, InternalProjectKeys, Project, Team, Webhook, backendContext, niceBackendFetch } from "../../../backend-helpers"; +import { ApiKey, Auth, InternalProjectKeys, Project, Webhook, backendContext, niceBackendFetch } from "../../../backend-helpers"; it("is not allowed to list permissions from the other users on the client", async ({ expect }) => { await Auth.Otp.signIn(); - const response = await niceBackendFetch(`/api/v1/user-permissions`, { + const response = await niceBackendFetch(`/api/v1/project-permissions`, { accessType: "client", method: "GET", }); @@ -21,7 +21,7 @@ it("is not allowed to list permissions from the other users on the client", asyn it("is not allowed to grant non-existing permission to a user on the server", async ({ expect }) => { const { userId } = await Auth.Otp.signIn(); - const response = await niceBackendFetch(`/api/v1/user-permissions/${userId}/does_not_exist`, { + const response = await niceBackendFetch(`/api/v1/project-permissions/${userId}/does_not_exist`, { accessType: "server", method: "POST", body: {}, @@ -47,7 +47,7 @@ it("can create a new permission and grant it to a user on the server", async ({ const { adminAccessToken } = await Project.createAndGetAdminToken({ config: { magic_link_enabled: true } }); // create a permission child - await niceBackendFetch(`/api/v1/user-permission-definitions`, { + await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "POST", body: { @@ -60,7 +60,7 @@ it("can create a new permission and grant it to a user on the server", async ({ }); // create a permission parent - await niceBackendFetch(`/api/v1/user-permission-definitions`, { + await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "POST", body: { @@ -78,7 +78,7 @@ it("can create a new permission and grant it to a user on the server", async ({ const { userId } = await Auth.Password.signUpWithEmail({ password: 'test1234' }); // list current permissions - const response1 = await niceBackendFetch(`/api/v1/user-permissions?user_id=me`, { + const response1 = await niceBackendFetch(`/api/v1/project-permissions?user_id=me`, { accessType: "client", method: "GET", }); @@ -94,7 +94,7 @@ it("can create a new permission and grant it to a user on the server", async ({ `); // grant new permission - const response2 = await niceBackendFetch(`/api/v1/user-permissions/${userId}/parent`, { + const response2 = await niceBackendFetch(`/api/v1/project-permissions/${userId}/parent`, { accessType: "server", method: "POST", body: {}, @@ -111,7 +111,7 @@ it("can create a new permission and grant it to a user on the server", async ({ `); // list current permissions (should have the new permission) - const response3 = await niceBackendFetch(`/api/v1/user-permissions?user_id=me`, { + const response3 = await niceBackendFetch(`/api/v1/project-permissions?user_id=me`, { accessType: "client", method: "GET", }); @@ -136,7 +136,7 @@ it("can customize default user permissions", async ({ expect }) => { await Auth.Otp.signIn(); const { adminAccessToken } = await Project.createAndGetAdminToken(); - const response1 = await niceBackendFetch(`/api/v1/user-permission-definitions`, { + const response1 = await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "POST", body: { @@ -202,7 +202,7 @@ it("can customize default user permissions", async ({ expect }) => { // sign up a new user const { userId } = await Auth.Password.signUpWithEmail({ password: 'test1234' }); // list permissions for the new user - const response3 = await niceBackendFetch(`/api/v1/user-permissions?user_id=${userId}`, { + const response3 = await niceBackendFetch(`/api/v1/project-permissions?user_id=${userId}`, { accessType: "client", method: "GET", }); @@ -223,18 +223,18 @@ it("can customize default user permissions", async ({ expect }) => { `); }); -it("should trigger user permission webhook when a permission is granted to a user", async ({ expect }) => { +it("should trigger project permission webhook when a permission is granted to a user", async ({ expect }) => { const { projectId, svixToken, endpointId } = await Webhook.createProjectWithEndpoint(); const { userId } = await Auth.Otp.signIn(); - await niceBackendFetch(`/api/v1/user-permission-definitions`, { + await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "POST", body: { id: 'test_permission' }, }); - const grantPermissionResponse = await niceBackendFetch(`/api/v1/user-permissions/${userId}/test_permission`, { + const grantPermissionResponse = await niceBackendFetch(`/api/v1/project-permissions/${userId}/test_permission`, { accessType: "server", method: "POST", body: {}, @@ -245,39 +245,39 @@ it("should trigger user permission webhook when a permission is granted to a use await wait(3000); const attemptResponse = await Webhook.listWebhookAttempts(projectId, endpointId, svixToken); - const userPermissionCreatedEvent = attemptResponse.find(event => event.eventType === "user_permission.created"); + const projectPermissionCreatedEvent = attemptResponse.find(event => event.eventType === "project_permission.created"); - expect(userPermissionCreatedEvent).toMatchInlineSnapshot(` + expect(projectPermissionCreatedEvent).toMatchInlineSnapshot(` { "channels": null, "eventId": null, - "eventType": "user_permission.created", + "eventType": "project_permission.created", "id": "", "payload": { "data": { "id": "test_permission", "user_id": "", }, - "type": "user_permission.created", + "type": "project_permission.created", }, "timestamp": , } `); }); -it("should trigger user permission webhook when a permission is revoked from a user", async ({ expect }) => { +it("should trigger project permission webhook when a permission is revoked from a user", async ({ expect }) => { const { projectId, svixToken, endpointId } = await Webhook.createProjectWithEndpoint(); const { userId } = await Auth.Otp.signIn(); - await niceBackendFetch(`/api/v1/user-permission-definitions`, { + await niceBackendFetch(`/api/v1/project-permission-definitions`, { accessType: "admin", method: "POST", body: { id: 'test_permission' }, }); // First grant the permission - const grantPermissionResponse = await niceBackendFetch(`/api/v1/user-permissions/${userId}/test_permission`, { + const grantPermissionResponse = await niceBackendFetch(`/api/v1/project-permissions/${userId}/test_permission`, { accessType: "server", method: "POST", body: {}, @@ -286,7 +286,7 @@ it("should trigger user permission webhook when a permission is revoked from a u expect(grantPermissionResponse.status).toBe(201); // Then revoke the permission - const revokePermissionResponse = await niceBackendFetch(`/api/v1/user-permissions/${userId}/test_permission`, { + const revokePermissionResponse = await niceBackendFetch(`/api/v1/project-permissions/${userId}/test_permission`, { accessType: "server", method: "DELETE", }); @@ -296,20 +296,20 @@ it("should trigger user permission webhook when a permission is revoked from a u await wait(3000); const attemptResponse = await Webhook.listWebhookAttempts(projectId, endpointId, svixToken); - const userPermissionDeletedEvent = attemptResponse.find(event => event.eventType === "user_permission.deleted"); + const projectPermissionDeletedEvent = attemptResponse.find(event => event.eventType === "project_permission.deleted"); - expect(userPermissionDeletedEvent).toMatchInlineSnapshot(` + expect(projectPermissionDeletedEvent).toMatchInlineSnapshot(` { "channels": null, "eventId": null, - "eventType": "user_permission.deleted", + "eventType": "project_permission.deleted", "id": "", "payload": { "data": { "id": "test_permission", "user_id": "", }, - "type": "user_permission.deleted", + "type": "project_permission.deleted", }, "timestamp": , } diff --git a/packages/stack-shared/src/interface/adminInterface.ts b/packages/stack-shared/src/interface/adminInterface.ts index d69040f90..5ed0dd02d 100644 --- a/packages/stack-shared/src/interface/adminInterface.ts +++ b/packages/stack-shared/src/interface/adminInterface.ts @@ -2,10 +2,10 @@ import { InternalSession } from "../sessions"; import { ApiKeysCrud } from "./crud/api-keys"; import { EmailTemplateCrud, EmailTemplateType } from "./crud/email-templates"; import { InternalEmailsCrud } from "./crud/emails"; +import { ProjectPermissionDefinitionsCrud } from "./crud/project-permissions"; import { ProjectsCrud } from "./crud/projects"; import { SvixTokenCrud } from "./crud/svix-token"; import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions"; -import { UserPermissionDefinitionsCrud } from "./crud/user-permissions"; import { ServerAuthApplicationOptions, StackServerInterface } from "./serverInterface"; export type AdminAuthApplicationOptions = ServerAuthApplicationOptions &( @@ -194,15 +194,15 @@ export class StackAdminInterface extends StackServerInterface { ); } - async listUserPermissionDefinitions(): Promise { - const response = await this.sendAdminRequest(`/user-permission-definitions`, {}, null); - const result = await response.json() as UserPermissionDefinitionsCrud['Admin']['List']; + async listProjectPermissionDefinitions(): Promise { + const response = await this.sendAdminRequest(`/project-permission-definitions`, {}, null); + const result = await response.json() as ProjectPermissionDefinitionsCrud['Admin']['List']; return result.items; } - async createUserPermissionDefinition(data: UserPermissionDefinitionsCrud['Admin']['Create']): Promise { + async createProjectPermissionDefinition(data: ProjectPermissionDefinitionsCrud['Admin']['Create']): Promise { const response = await this.sendAdminRequest( - "/user-permission-definitions", + "/project-permission-definitions", { method: "POST", headers: { @@ -215,9 +215,9 @@ export class StackAdminInterface extends StackServerInterface { return await response.json(); } - async updateUserPermissionDefinition(permissionId: string, data: UserPermissionDefinitionsCrud['Admin']['Update']): Promise { + async updateProjectPermissionDefinition(permissionId: string, data: ProjectPermissionDefinitionsCrud['Admin']['Update']): Promise { const response = await this.sendAdminRequest( - `/user-permission-definitions/${permissionId}`, + `/project-permission-definitions/${permissionId}`, { method: "PATCH", headers: { @@ -230,9 +230,9 @@ export class StackAdminInterface extends StackServerInterface { return await response.json(); } - async deleteUserPermissionDefinition(permissionId: string): Promise { + async deleteProjectPermissionDefinition(permissionId: string): Promise { await this.sendAdminRequest( - `/user-permission-definitions/${permissionId}`, + `/project-permission-definitions/${permissionId}`, { method: "DELETE" }, null, ); diff --git a/packages/stack-shared/src/interface/clientInterface.ts b/packages/stack-shared/src/interface/clientInterface.ts index edd080520..f6ea29499 100644 --- a/packages/stack-shared/src/interface/clientInterface.ts +++ b/packages/stack-shared/src/interface/clientInterface.ts @@ -19,6 +19,7 @@ import { InternalProjectsCrud, ProjectsCrud } from './crud/projects'; import { SessionsCrud } from './crud/sessions'; import { TeamInvitationCrud } from './crud/team-invitation'; import { TeamMemberProfilesCrud } from './crud/team-member-profiles'; +import { ProjectPermissionsCrud } from './crud/project-permissions'; import { TeamPermissionsCrud } from './crud/team-permissions'; import { TeamsCrud } from './crud/teams'; @@ -1183,6 +1184,21 @@ export class StackClientInterface { return result.items; } + async listCurrentUserProjectPermissions( + options: { + recursive: boolean, + }, + session: InternalSession + ): Promise { + const response = await this.sendClientRequest( + `/project-permissions?user_id=me&recursive=${options.recursive}`, + {}, + session, + ); + const result = await response.json() as ProjectPermissionsCrud['Client']['List']; + return result.items; + } + async listCurrentUserTeams(session: InternalSession): Promise { const response = await this.sendClientRequest( "/teams?user_id=me", diff --git a/packages/stack-shared/src/interface/crud/project-permissions.ts b/packages/stack-shared/src/interface/crud/project-permissions.ts new file mode 100644 index 000000000..e25c7e34e --- /dev/null +++ b/packages/stack-shared/src/interface/crud/project-permissions.ts @@ -0,0 +1,118 @@ +import { CrudTypeOf, createCrud } from "../../crud"; +import * as schemaFields from "../../schema-fields"; +import { yupMixed, yupObject } from "../../schema-fields"; +import { WebhookEvent } from "../webhooks"; + +// =============== Project permissions ================= + +export const projectPermissionsCrudClientReadSchema = yupObject({ + id: schemaFields.permissionDefinitionIdSchema.defined(), + user_id: schemaFields.userIdSchema.defined(), +}).defined(); + +export const projectPermissionsCrudServerCreateSchema = yupObject({ +}).defined(); + +export const projectPermissionsCrudServerDeleteSchema = yupMixed(); + +export const projectPermissionsCrud = createCrud({ + clientReadSchema: projectPermissionsCrudClientReadSchema, + serverCreateSchema: projectPermissionsCrudServerCreateSchema, + serverDeleteSchema: projectPermissionsCrudServerDeleteSchema, + docs: { + clientList: { + summary: "List project permissions", + description: "List global permissions of the current user. `user_id=me` must be set for client requests. `(user_id, permission_id)` together uniquely identify a permission.", + tags: ["Permissions"], + }, + serverList: { + summary: "List project permissions", + description: "Query and filter the permission with `user_id` and `permission_id`. `(user_id, permission_id)` together uniquely identify a permission.", + tags: ["Permissions"], + }, + serverCreate: { + summary: "Grant a global permission to a user", + description: "Grant a global permission to a user (the permission must be created first on the Stack dashboard)", + tags: ["Permissions"], + }, + serverDelete: { + summary: "Revoke a global permission from a user", + description: "Revoke a global permission from a user", + tags: ["Permissions"], + }, + }, +}); +export type ProjectPermissionsCrud = CrudTypeOf; + +export const projectPermissionCreatedWebhookEvent = { + type: "project_permission.created", + schema: projectPermissionsCrud.server.readSchema, + metadata: { + summary: "Project Permission Created", + description: "This event is triggered when a project permission is created.", + tags: ["Users"], + }, +} satisfies WebhookEvent; + +export const projectPermissionDeletedWebhookEvent = { + type: "project_permission.deleted", + schema: projectPermissionsCrud.server.readSchema, + metadata: { + summary: "Project Permission Deleted", + description: "This event is triggered when a project permission is deleted.", + tags: ["Users"], + }, +} satisfies WebhookEvent; + +// =============== Project permission definitions ================= + +export const projectPermissionDefinitionsCrudAdminReadSchema = yupObject({ + id: schemaFields.permissionDefinitionIdSchema.defined(), + description: schemaFields.teamPermissionDescriptionSchema.optional(), + contained_permission_ids: schemaFields.containedPermissionIdsSchema.defined(), +}).defined(); + +export const projectPermissionDefinitionsCrudAdminCreateSchema = yupObject({ + id: schemaFields.customPermissionDefinitionIdSchema.defined(), + description: schemaFields.teamPermissionDescriptionSchema.optional(), + contained_permission_ids: schemaFields.containedPermissionIdsSchema.optional(), +}).defined(); + +export const projectPermissionDefinitionsCrudAdminUpdateSchema = yupObject({ + id: schemaFields.customPermissionDefinitionIdSchema.optional(), + description: schemaFields.teamPermissionDescriptionSchema.optional(), + contained_permission_ids: schemaFields.containedPermissionIdsSchema.optional(), +}).defined(); + +export const projectPermissionDefinitionsCrudAdminDeleteSchema = yupMixed(); + +export const projectPermissionDefinitionsCrud = createCrud({ + adminReadSchema: projectPermissionDefinitionsCrudAdminReadSchema, + adminCreateSchema: projectPermissionDefinitionsCrudAdminCreateSchema, + adminUpdateSchema: projectPermissionDefinitionsCrudAdminUpdateSchema, + adminDeleteSchema: projectPermissionDefinitionsCrudAdminDeleteSchema, + docs: { + adminList: { + summary: "List project permission definitions", + description: "Query and filter project permission definitions (the equivalent of listing permissions on the Stack dashboard)", + tags: ["Permissions"], + }, + adminCreate: { + summary: "Create a new project permission definition", + description: "Create a new project permission definition (the equivalent of creating a new permission on the Stack dashboard)", + tags: ["Permissions"], + }, + adminUpdate: { + summary: "Update a project permission definition", + description: "Update a project permission definition (the equivalent of updating a permission on the Stack dashboard)", + tags: ["Permissions"], + }, + adminDelete: { + summary: "Delete a project permission definition", + description: "Delete a project permission definition (the equivalent of deleting a permission on the Stack dashboard)", + tags: ["Permissions"], + }, + }, +}); + +export type ProjectPermissionDefinitionsCrud = CrudTypeOf; diff --git a/packages/stack-shared/src/interface/crud/team-permissions.ts b/packages/stack-shared/src/interface/crud/team-permissions.ts index 804c31b66..4361930af 100644 --- a/packages/stack-shared/src/interface/crud/team-permissions.ts +++ b/packages/stack-shared/src/interface/crud/team-permissions.ts @@ -6,7 +6,7 @@ import { WebhookEvent } from "../webhooks"; // =============== Team permissions ================= export const teamPermissionsCrudClientReadSchema = yupObject({ - id: schemaFields.teamPermissionDefinitionIdSchema.defined(), + id: schemaFields.permissionDefinitionIdSchema.defined(), user_id: schemaFields.userIdSchema.defined(), team_id: schemaFields.teamIdSchema.defined(), }).defined(); @@ -68,19 +68,19 @@ export const teamPermissionDeletedWebhookEvent = { // =============== Team permission definitions ================= export const teamPermissionDefinitionsCrudAdminReadSchema = yupObject({ - id: schemaFields.teamPermissionDefinitionIdSchema.defined(), + id: schemaFields.permissionDefinitionIdSchema.defined(), description: schemaFields.teamPermissionDescriptionSchema.optional(), contained_permission_ids: schemaFields.containedPermissionIdsSchema.defined(), }).defined(); export const teamPermissionDefinitionsCrudAdminCreateSchema = yupObject({ - id: schemaFields.customTeamPermissionDefinitionIdSchema.defined(), + id: schemaFields.customPermissionDefinitionIdSchema.defined(), description: schemaFields.teamPermissionDescriptionSchema.optional(), contained_permission_ids: schemaFields.containedPermissionIdsSchema.optional(), }).defined(); export const teamPermissionDefinitionsCrudAdminUpdateSchema = yupObject({ - id: schemaFields.customTeamPermissionDefinitionIdSchema.optional(), + id: schemaFields.customPermissionDefinitionIdSchema.optional(), description: schemaFields.teamPermissionDescriptionSchema.optional(), contained_permission_ids: schemaFields.containedPermissionIdsSchema.optional(), }).defined(); diff --git a/packages/stack-shared/src/interface/crud/user-permissions.ts b/packages/stack-shared/src/interface/crud/user-permissions.ts deleted file mode 100644 index f8c869413..000000000 --- a/packages/stack-shared/src/interface/crud/user-permissions.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { CrudTypeOf, createCrud } from "../../crud"; -import * as schemaFields from "../../schema-fields"; -import { yupMixed, yupObject } from "../../schema-fields"; -import { WebhookEvent } from "../webhooks"; - -// =============== User permissions ================= - -export const userPermissionsCrudClientReadSchema = yupObject({ - id: schemaFields.teamPermissionDefinitionIdSchema.defined(), - user_id: schemaFields.userIdSchema.defined(), -}).defined(); - -export const userPermissionsCrudServerCreateSchema = yupObject({ -}).defined(); - -export const userPermissionsCrudServerDeleteSchema = yupMixed(); - -export const userPermissionsCrud = createCrud({ - clientReadSchema: userPermissionsCrudClientReadSchema, - serverCreateSchema: userPermissionsCrudServerCreateSchema, - serverDeleteSchema: userPermissionsCrudServerDeleteSchema, - docs: { - clientList: { - summary: "List user permissions", - description: "List global permissions of the current user. `user_id=me` must be set for client requests. `(user_id, permission_id)` together uniquely identify a permission.", - tags: ["Permissions"], - }, - serverList: { - summary: "List user permissions", - description: "Query and filter the permission with `user_id` and `permission_id`. `(user_id, permission_id)` together uniquely identify a permission.", - tags: ["Permissions"], - }, - serverCreate: { - summary: "Grant a global permission to a user", - description: "Grant a global permission to a user (the permission must be created first on the Stack dashboard)", - tags: ["Permissions"], - }, - serverDelete: { - summary: "Revoke a global permission from a user", - description: "Revoke a global permission from a user", - tags: ["Permissions"], - }, - }, -}); -export type UserPermissionsCrud = CrudTypeOf; - -export const userPermissionCreatedWebhookEvent = { - type: "user_permission.created", - schema: userPermissionsCrud.server.readSchema, - metadata: { - summary: "User Permission Created", - description: "This event is triggered when a user permission is created.", - tags: ["Users"], - }, -} satisfies WebhookEvent; - -export const userPermissionDeletedWebhookEvent = { - type: "user_permission.deleted", - schema: userPermissionsCrud.server.readSchema, - metadata: { - summary: "User Permission Deleted", - description: "This event is triggered when a user permission is deleted.", - tags: ["Users"], - }, -} satisfies WebhookEvent; - -// =============== User permission definitions ================= - -export const userPermissionDefinitionsCrudAdminReadSchema = yupObject({ - id: schemaFields.teamPermissionDefinitionIdSchema.defined(), - description: schemaFields.teamPermissionDescriptionSchema.optional(), - contained_permission_ids: schemaFields.containedPermissionIdsSchema.defined(), -}).defined(); - -export const userPermissionDefinitionsCrudAdminCreateSchema = yupObject({ - id: schemaFields.customTeamPermissionDefinitionIdSchema.defined(), - description: schemaFields.teamPermissionDescriptionSchema.optional(), - contained_permission_ids: schemaFields.containedPermissionIdsSchema.optional(), -}).defined(); - -export const userPermissionDefinitionsCrudAdminUpdateSchema = yupObject({ - id: schemaFields.customTeamPermissionDefinitionIdSchema.optional(), - description: schemaFields.teamPermissionDescriptionSchema.optional(), - contained_permission_ids: schemaFields.containedPermissionIdsSchema.optional(), -}).defined(); - -export const userPermissionDefinitionsCrudAdminDeleteSchema = yupMixed(); - -export const userPermissionDefinitionsCrud = createCrud({ - adminReadSchema: userPermissionDefinitionsCrudAdminReadSchema, - adminCreateSchema: userPermissionDefinitionsCrudAdminCreateSchema, - adminUpdateSchema: userPermissionDefinitionsCrudAdminUpdateSchema, - adminDeleteSchema: userPermissionDefinitionsCrudAdminDeleteSchema, - docs: { - adminList: { - summary: "List user permission definitions", - description: "Query and filter user permission definitions (the equivalent of listing permissions on the Stack dashboard)", - tags: ["Permissions"], - }, - adminCreate: { - summary: "Create a new user permission definition", - description: "Create a new user permission definition (the equivalent of creating a new permission on the Stack dashboard)", - tags: ["Permissions"], - }, - adminUpdate: { - summary: "Update a user permission definition", - description: "Update a user permission definition (the equivalent of updating a permission on the Stack dashboard)", - tags: ["Permissions"], - }, - adminDelete: { - summary: "Delete a user permission definition", - description: "Delete a user permission definition (the equivalent of deleting a permission on the Stack dashboard)", - tags: ["Permissions"], - }, - }, -}); - -export type UserPermissionDefinitionsCrud = CrudTypeOf; diff --git a/packages/stack-shared/src/interface/serverInterface.ts b/packages/stack-shared/src/interface/serverInterface.ts index 2600bfc1b..8fd6cee28 100644 --- a/packages/stack-shared/src/interface/serverInterface.ts +++ b/packages/stack-shared/src/interface/serverInterface.ts @@ -15,6 +15,7 @@ import { SessionsCrud } from "./crud/sessions"; import { TeamInvitationCrud } from "./crud/team-invitation"; import { TeamMemberProfilesCrud } from "./crud/team-member-profiles"; import { TeamMembershipsCrud } from "./crud/team-memberships"; +import { ProjectPermissionsCrud } from "./crud/project-permissions"; import { TeamPermissionsCrud } from "./crud/team-permissions"; import { TeamsCrud } from "./crud/teams"; import { UsersCrud } from "./crud/users"; @@ -195,6 +196,25 @@ export class StackServerInterface extends StackClientInterface { return result.items; } + async listServerProjectPermissions( + options: { + userId?: string, + recursive: boolean, + }, + session: InternalSession | null, + ): Promise { + const response = await this.sendServerRequest( + `/project-permissions?${new URLSearchParams(filterUndefined({ + user_id: options.userId, + recursive: options.recursive.toString(), + }))}`, + {}, + session, + ); + const result = await response.json() as ProjectPermissionsCrud['Server']['List']; + return result.items; + } + async listServerUsers(options: { cursor?: string, limit?: number, diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index 899bfc581..6f8e60a6d 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -1104,9 +1104,9 @@ const TeamMembershipAlreadyExists = createKnownErrorConstructor( () => [] as const, ); -const UserPermissionRequired = createKnownErrorConstructor( +const ProjectPermissionRequired = createKnownErrorConstructor( KnownError, - "USER_PERMISSION_REQUIRED", + "PROJECT_PERMISSION_REQUIRED", (userId, permissionId) => [ 401, `User ${userId} does not have permission ${permissionId}.`, @@ -1308,7 +1308,7 @@ export const KnownErrors = { InvalidTotpCode, UserAuthenticationRequired, TeamMembershipAlreadyExists, - UserPermissionRequired, + ProjectPermissionRequired, TeamPermissionRequired, InvalidSharedOAuthProviderId, InvalidStandardOAuthProviderId, diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index 8b1e68145..124ac2fc2 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -359,7 +359,7 @@ export const teamSystemPermissions = [ '$remove_members', '$invite_members', ] as const; -export const teamPermissionDefinitionIdSchema = yupString() +export const permissionDefinitionIdSchema = yupString() .matches(/^\$?[a-z0-9_:]+$/, 'Only lowercase letters, numbers, ":", "_" and optional "$" at the beginning are allowed') .test('is-system-permission', 'System permissions must start with a dollar sign', (value, ctx) => { if (!value) return true; @@ -369,11 +369,11 @@ export const teamPermissionDefinitionIdSchema = yupString() return true; }) .meta({ openapiField: { description: `The permission ID used to uniquely identify a permission. Can either be a custom permission with lowercase letters, numbers, \`:\`, and \`_\` characters, or one of the system permissions: ${teamSystemPermissions.map(x => `\`${x}\``).join(', ')}`, exampleValue: 'read_secret_info' } }); -export const customTeamPermissionDefinitionIdSchema = yupString() +export const customPermissionDefinitionIdSchema = yupString() .matches(/^[a-z0-9_:]+$/, 'Only lowercase letters, numbers, ":", "_" are allowed') .meta({ openapiField: { description: 'The permission ID used to uniquely identify a permission. Can only contain lowercase letters, numbers, ":", and "_" characters', exampleValue: 'read_secret_info' } }); export const teamPermissionDescriptionSchema = yupString().meta({ openapiField: { description: 'A human-readable description of the permission', exampleValue: 'Read secret information' } }); -export const containedPermissionIdsSchema = yupArray(teamPermissionDefinitionIdSchema.defined()).meta({ openapiField: { description: 'The IDs of the permissions that are contained in this permission', exampleValue: ['read_public_info'] } }); +export const containedPermissionIdsSchema = yupArray(permissionDefinitionIdSchema.defined()).meta({ openapiField: { description: 'The IDs of the permissions that are contained in this permission', exampleValue: ['read_public_info'] } }); // Teams export const teamIdSchema = yupString().uuid().meta({ openapiField: { description: _idDescription('team'), exampleValue: 'ad962777-8244-496a-b6a2-e0c6a449c79e' } }); diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index f28ebbee3..8cf699af8 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -12,7 +12,7 @@ import { AdminSentEmail } from "../.."; import { ApiKey, ApiKeyBase, ApiKeyBaseCrudRead, ApiKeyCreateOptions, ApiKeyFirstView, apiKeyCreateOptionsToCrud } from "../../api-keys"; import { EmailConfig, stackAppInternalsSymbol } from "../../common"; import { AdminEmailTemplate, AdminEmailTemplateUpdateOptions, adminEmailTemplateUpdateOptionsToCrud } from "../../email-templates"; -import { AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, AdminUserPermission, AdminUserPermissionDefinition, AdminUserPermissionDefinitionCreateOptions, AdminUserPermissionDefinitionUpdateOptions, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud, adminUserPermissionDefinitionCreateOptionsToCrud, adminUserPermissionDefinitionUpdateOptionsToCrud } from "../../permissions"; +import { AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, adminTeamPermissionDefinitionCreateOptionsToCrud, adminTeamPermissionDefinitionUpdateOptionsToCrud, adminProjectPermissionDefinitionCreateOptionsToCrud, adminProjectPermissionDefinitionUpdateOptionsToCrud } from "../../permissions"; import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, adminProjectUpdateOptionsToCrud } from "../../projects"; import { StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app"; import { clientVersion, createCache, getBaseUrl, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey } from "./common"; @@ -37,8 +37,8 @@ export class _StackAdminAppImplIncomplete { return await this._interface.listTeamPermissionDefinitions(); }); - private readonly _adminUserPermissionDefinitionsCache = createCache(async () => { - return await this._interface.listUserPermissionDefinitions(); + private readonly _adminProjectPermissionDefinitionsCache = createCache(async () => { + return await this._interface.listProjectPermissionDefinitions(); }); private readonly _svixTokenCache = createCache(async () => { return await this._interface.getSvixToken(); @@ -296,32 +296,32 @@ export class _StackAdminAppImplIncomplete { - const crud = await this._interface.createUserPermissionDefinition(adminUserPermissionDefinitionCreateOptionsToCrud(data)); - await this._adminUserPermissionDefinitionsCache.refresh([]); - return this._serverUserPermissionDefinitionFromCrud(crud); + async createProjectPermissionDefinition(data: AdminProjectPermissionDefinitionCreateOptions): Promise { + const crud = await this._interface.createProjectPermissionDefinition(adminProjectPermissionDefinitionCreateOptionsToCrud(data)); + await this._adminProjectPermissionDefinitionsCache.refresh([]); + return this._serverProjectPermissionDefinitionFromCrud(crud); } - async updateUserPermissionDefinition(permissionId: string, data: AdminUserPermissionDefinitionUpdateOptions) { - await this._interface.updateUserPermissionDefinition(permissionId, adminUserPermissionDefinitionUpdateOptionsToCrud(data)); - await this._adminUserPermissionDefinitionsCache.refresh([]); + async updateProjectPermissionDefinition(permissionId: string, data: AdminProjectPermissionDefinitionUpdateOptions) { + await this._interface.updateProjectPermissionDefinition(permissionId, adminProjectPermissionDefinitionUpdateOptionsToCrud(data)); + await this._adminProjectPermissionDefinitionsCache.refresh([]); } - async deleteUserPermissionDefinition(permissionId: string): Promise { - await this._interface.deleteUserPermissionDefinition(permissionId); - await this._adminUserPermissionDefinitionsCache.refresh([]); + async deleteProjectPermissionDefinition(permissionId: string): Promise { + await this._interface.deleteProjectPermissionDefinition(permissionId); + await this._adminProjectPermissionDefinitionsCache.refresh([]); } - async listUserPermissionDefinitions(): Promise { - const crud = Result.orThrow(await this._adminUserPermissionDefinitionsCache.getOrWait([], "write-only")); - return crud.map((p) => this._serverUserPermissionDefinitionFromCrud(p)); + async listProjectPermissionDefinitions(): Promise { + const crud = Result.orThrow(await this._adminProjectPermissionDefinitionsCache.getOrWait([], "write-only")); + return crud.map((p) => this._serverProjectPermissionDefinitionFromCrud(p)); } // IF_PLATFORM react-like - useUserPermissionDefinitions(): AdminUserPermissionDefinition[] { - const crud = useAsyncCache(this._adminUserPermissionDefinitionsCache, [], "useUserPermissions()"); + useProjectPermissionDefinitions(): AdminProjectPermissionDefinition[] { + const crud = useAsyncCache(this._adminProjectPermissionDefinitionsCache, [], "useProjectPermissions()"); return useMemo(() => { - return crud.map((p) => this._serverUserPermissionDefinitionFromCrud(p)); + return crud.map((p) => this._serverProjectPermissionDefinitionFromCrud(p)); }, [crud]); } // END_PLATFORM diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index bfdf2a840..e8be1ad34 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -2,6 +2,7 @@ import { WebAuthnError, startAuthentication, startRegistration } from "@simplewe import { KnownErrors, StackClientInterface } from "@stackframe/stack-shared"; import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/crud/contact-channels"; import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user"; +import { ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { SessionsCrud } from "@stackframe/stack-shared/dist/interface/crud/sessions"; import { TeamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation"; @@ -24,8 +25,6 @@ import { deindent, mergeScopeStrings } from "@stackframe/stack-shared/dist/utils import { getRelativePart, isRelative } from "@stackframe/stack-shared/dist/utils/urls"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import * as cookie from "cookie"; -import * as NextNavigationUnscrambled from "next/navigation"; // import the entire module to get around some static compiler warnings emitted by Next.js in some cases | THIS_LINE_PLATFORM next -import React, { useCallback, useMemo } from "react"; // THIS_LINE_PLATFORM react-like import { constructRedirectUrl } from "../../../../utils/url"; import { addNewOAuthProviderOrScope, callOAuthCallback, signInWithOAuth } from "../../../auth"; import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookieClient, getCookieClient, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie"; @@ -38,8 +37,10 @@ import { EditableTeamMemberProfile, Team, TeamCreateOptions, TeamInvitation, Tea import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, ProjectCurrentUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud } from "../../users"; import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app"; import { _StackAdminAppImplIncomplete } from "./admin-app-impl"; -import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls } from "./common"; +import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, } from "./common"; +import * as NextNavigationUnscrambled from "next/navigation"; // import the entire module to get around some static compiler warnings emitted by Next.js in some cases | THIS_LINE_PLATFORM next +import React, { useCallback, useMemo } from "react"; // THIS_LINE_PLATFORM react-like import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like let isReactServer = false; @@ -115,6 +116,12 @@ export class _StackClientAppImplIncomplete(async (session, [teamId, recursive]) => { return await this._interface.listCurrentUserTeamPermissions({ teamId, recursive }, session); }); + private readonly _currentUserProjectPermissionsCache = createCacheBySession< + [boolean], + ProjectPermissionsCrud['Client']['Read'][] + >(async (session, [recursive]) => { + return await this._interface.listCurrentUserProjectPermissions({ recursive }, session); + }); private readonly _currentUserTeamsCache = createCacheBySession(async (session) => { return await this._interface.listCurrentUserTeams(session); }); @@ -584,7 +591,7 @@ export class _StackClientAppImplIncomplete { - const recursive = options?.recursive ?? true; - const permissions = Result.orThrow(await app._currentUserPermissionsCache.getOrWait([session, scope.id, recursive], "write-only")); - return permissions.map((crud) => app._clientTeamPermissionFromCrud(crud)); + async listPermissions(scopeOrOptions?: Team | { recursive?: boolean }, options?: { recursive?: boolean }): Promise { + if (scopeOrOptions && 'id' in scopeOrOptions) { + const scope = scopeOrOptions; + const recursive = options?.recursive ?? true; + const permissions = Result.orThrow(await app._currentUserPermissionsCache.getOrWait([session, scope.id, recursive], "write-only")); + return permissions.map((crud) => app._clientPermissionFromCrud(crud)); + } else { + const opts = scopeOrOptions; + const recursive = opts?.recursive ?? true; + const permissions = Result.orThrow(await app._currentUserProjectPermissionsCache.getOrWait([session, recursive], "write-only")); + return permissions.map((crud) => app._clientPermissionFromCrud(crud)); + } }, // IF_PLATFORM react-like - usePermissions(scope: Team, options?: { recursive?: boolean }): TeamPermission[] { - const recursive = options?.recursive ?? true; - const permissions = useAsyncCache(app._currentUserPermissionsCache, [session, scope.id, recursive] as const, "user.usePermissions()"); - return useMemo(() => permissions.map((crud) => app._clientTeamPermissionFromCrud(crud)), [permissions]); + usePermissions(scopeOrOptions?: Team | { recursive?: boolean }, options?: { recursive?: boolean }): TeamPermission[] { + if (scopeOrOptions && 'id' in scopeOrOptions) { + const scope = scopeOrOptions; + const recursive = options?.recursive ?? true; + const permissions = useAsyncCache(app._currentUserPermissionsCache, [session, scope.id, recursive] as const, "user.usePermissions()"); + return useMemo(() => permissions.map((crud) => app._clientPermissionFromCrud(crud)), [permissions]); + } else { + const opts = scopeOrOptions; + const recursive = opts?.recursive ?? true; + const permissions = useAsyncCache(app._currentUserProjectPermissionsCache, [session, recursive] as const, "user.usePermissions()"); + return useMemo(() => permissions.map((crud) => app._clientPermissionFromCrud(crud)), [permissions]); + } }, // END_PLATFORM // IF_PLATFORM react-like - usePermission(scope: Team, permissionId: string): TeamPermission | null { - const permissions = this.usePermissions(scope); - return useMemo(() => permissions.find((p) => p.id === permissionId) ?? null, [permissions, permissionId]); + usePermission(scopeOrPermissionId: Team | string, permissionId?: string): TeamPermission | null { + if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string') { + const scope = scopeOrPermissionId; + const permissions = this.usePermissions(scope); + return useMemo(() => permissions.find((p) => p.id === permissionId) ?? null, [permissions, permissionId]); + } else { + const pid = scopeOrPermissionId; + const permissions = this.usePermissions(); + return useMemo(() => permissions.find((p) => p.id === pid) ?? null, [permissions, pid]); + } }, // END_PLATFORM - async getPermission(scope: Team, permissionId: string): Promise { - const permissions = await this.listPermissions(scope); - return permissions.find((p) => p.id === permissionId) ?? null; + async getPermission(scopeOrPermissionId: Team | string, permissionId?: string): Promise { + if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string') { + const scope = scopeOrPermissionId; + const permissions = await this.listPermissions(scope); + return permissions.find((p) => p.id === permissionId) ?? null; + } else { + const pid = scopeOrPermissionId; + const permissions = await this.listPermissions(); + return permissions.find((p) => p.id === pid) ?? null; + } }, - async hasPermission(scope: Team, permissionId: string): Promise { - return (await this.getPermission(scope, permissionId)) !== null; + async hasPermission(scopeOrPermissionId: Team | string, permissionId?: string): Promise { + if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string') { + const scope = scopeOrPermissionId; + return (await this.getPermission(scope, permissionId as string)) !== null; + } else { + const pid = scopeOrPermissionId; + return (await this.getPermission(pid)) !== null; + } }, async update(update) { return await app._updateClientUser(update, session); diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 38ffe0128..2f02acf9b 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -3,7 +3,7 @@ import { ContactChannelsCrud } from "@stackframe/stack-shared/dist/interface/cru import { TeamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation"; import { TeamMemberProfilesCrud } from "@stackframe/stack-shared/dist/interface/crud/team-member-profiles"; import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions"; -import { UserPermissionDefinitionsCrud } from "@stackframe/stack-shared/dist/interface/crud/user-permissions"; +import { ProjectPermissionDefinitionsCrud, ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; @@ -17,7 +17,7 @@ import { constructRedirectUrl } from "../../../../utils/url"; import { GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, TokenStoreInit } from "../../common"; import { OAuthConnection } from "../../connected-accounts"; import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactChannelUpdateOptions, serverContactChannelCreateOptionsToCrud, serverContactChannelUpdateOptionsToCrud } from "../../contact-channels"; -import { AdminTeamPermission, AdminTeamPermissionDefinition, AdminUserPermissionDefinition } from "../../permissions"; +import { AdminTeamPermission, AdminTeamPermissionDefinition, AdminProjectPermissionDefinition } from "../../permissions"; import { EditableTeamMemberProfile, ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions, ServerTeamUpdateOptions, ServerTeamUser, Team, TeamInvitation, serverTeamCreateOptionsToCrud, serverTeamUpdateOptionsToCrud } from "../../teams"; import { ProjectCurrentServerUser, ServerUser, ServerUserCreateOptions, ServerUserUpdateOptions, serverUserCreateOptionsToCrud, serverUserUpdateOptionsToCrud } from "../../users"; import { StackServerAppConstructorOptions } from "../interfaces/server-app"; @@ -61,6 +61,12 @@ export class _StackServerAppImplIncomplete(async ([teamId, userId, recursive]) => { return await this._interface.listServerTeamPermissions({ teamId, userId, recursive }, null); }); + private readonly _serverUserProjectPermissionsCache = createCache< + [string, boolean], + ProjectPermissionsCrud['Server']['Read'][] + >(async ([userId, recursive]) => { + return await this._interface.listServerProjectPermissions({ userId, recursive }, null); + }); private readonly _serverUserOAuthConnectionAccessTokensCache = createCache<[string, string, string], { accessToken: string } | null>( async ([userId, providerId, scope]) => { try { @@ -316,30 +322,66 @@ export class _StackServerAppImplIncomplete { - const recursive = options?.recursive ?? true; - const permissions = Result.orThrow(await app._serverTeamUserPermissionsCache.getOrWait([scope.id, crud.id, recursive], "write-only")); - return permissions.map((crud) => app._serverPermissionFromCrud(crud)); + async listPermissions(scopeOrOptions?: Team | { recursive?: boolean }, options?: { recursive?: boolean }): Promise { + if (scopeOrOptions && 'id' in scopeOrOptions) { + const scope = scopeOrOptions; + const recursive = options?.recursive ?? true; + const permissions = Result.orThrow(await app._serverTeamUserPermissionsCache.getOrWait([scope.id, crud.id, recursive], "write-only")); + return permissions.map((crud) => app._serverPermissionFromCrud(crud)); + } else { + const opts = scopeOrOptions; + const recursive = opts?.recursive ?? true; + const permissions = Result.orThrow(await app._serverUserProjectPermissionsCache.getOrWait([crud.id, recursive], "write-only")); + return permissions.map((crud) => app._serverPermissionFromCrud(crud)); + } }, // IF_PLATFORM react-like - usePermissions(scope: Team, options?: { recursive?: boolean }): AdminTeamPermission[] { - const recursive = options?.recursive ?? true; - const permissions = useAsyncCache(app._serverTeamUserPermissionsCache, [scope.id, crud.id, recursive] as const, "user.usePermissions()"); - return useMemo(() => permissions.map((crud) => app._serverPermissionFromCrud(crud)), [permissions]); + usePermissions(scopeOrOptions?: Team | { recursive?: boolean }, options?: { recursive?: boolean }): AdminTeamPermission[] { + if (scopeOrOptions && 'id' in scopeOrOptions) { + const scope = scopeOrOptions; + const recursive = options?.recursive ?? true; + const permissions = useAsyncCache(app._serverTeamUserPermissionsCache, [scope.id, crud.id, recursive] as const, "user.usePermissions()"); + return useMemo(() => permissions.map((crud) => app._serverPermissionFromCrud(crud)), [permissions]); + } else { + const opts = scopeOrOptions; + const recursive = opts?.recursive ?? true; + const permissions = useAsyncCache(app._serverUserProjectPermissionsCache, [crud.id, recursive] as const, "user.usePermissions()"); + return useMemo(() => permissions.map((crud) => app._serverPermissionFromCrud(crud)), [permissions]); + } }, // END_PLATFORM - async getPermission(scope: Team, permissionId: string): Promise { - const permissions = await this.listPermissions(scope); - return permissions.find((p) => p.id === permissionId) ?? null; + async getPermission(scopeOrPermissionId: Team | string, permissionId?: string): Promise { + if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string') { + const scope = scopeOrPermissionId; + const permissions = await this.listPermissions(scope); + return permissions.find((p) => p.id === permissionId) ?? null; + } else { + const pid = scopeOrPermissionId; + const permissions = await this.listPermissions(); + return permissions.find((p) => p.id === pid) ?? null; + } }, // IF_PLATFORM react-like - usePermission(scope: Team, permissionId: string): AdminTeamPermission | null { - const permissions = this.usePermissions(scope); - return useMemo(() => permissions.find((p) => p.id === permissionId) ?? null, [permissions, permissionId]); + usePermission(scopeOrPermissionId: Team | string, permissionId?: string): AdminTeamPermission | null { + if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string') { + const scope = scopeOrPermissionId; + const permissions = this.usePermissions(scope); + return useMemo(() => permissions.find((p) => p.id === permissionId) ?? null, [permissions, permissionId]); + } else { + const pid = scopeOrPermissionId; + const permissions = this.usePermissions(); + return useMemo(() => permissions.find((p) => p.id === pid) ?? null, [permissions, pid]); + } }, // END_PLATFORM - async hasPermission(scope: Team, permissionId: string): Promise { - return await this.getPermission(scope, permissionId) !== null; + async hasPermission(scopeOrPermissionId: Team | string, permissionId?: string): Promise { + if (scopeOrPermissionId && typeof scopeOrPermissionId !== 'string') { + const scope = scopeOrPermissionId; + return (await this.getPermission(scope, permissionId as string)) !== null; + } else { + const pid = scopeOrPermissionId; + return (await this.getPermission(pid)) !== null; + } }, async update(update: ServerUserUpdateOptions) { await app._updateServerUser(crud.id, update); @@ -626,7 +668,7 @@ export class _StackServerAppImplIncomplete & AsyncStoreProperty<"apiKeys", [], ApiKey[], true> & AsyncStoreProperty<"teamPermissionDefinitions", [], AdminTeamPermissionDefinition[], true> - & AsyncStoreProperty<"userPermissionDefinitions", [], AdminUserPermissionDefinition[], true> + & AsyncStoreProperty<"projectPermissionDefinitions", [], AdminProjectPermissionDefinition[], true> & { useEmailTemplates(): AdminEmailTemplate[], // THIS_LINE_PLATFORM react-like listEmailTemplates(): Promise, @@ -44,9 +44,9 @@ export type StackAdminApp, deleteTeamPermissionDefinition(permissionId: string): Promise, - createUserPermissionDefinition(data: AdminUserPermissionDefinitionCreateOptions): Promise, - updateUserPermissionDefinition(permissionId: string, data: AdminUserPermissionDefinitionUpdateOptions): Promise, - deleteUserPermissionDefinition(permissionId: string): Promise, + createProjectPermissionDefinition(data: AdminProjectPermissionDefinitionCreateOptions): Promise, + updateProjectPermissionDefinition(permissionId: string, data: AdminProjectPermissionDefinitionUpdateOptions): Promise, + deleteProjectPermissionDefinition(permissionId: string): Promise, useSvixToken(): string, // THIS_LINE_PLATFORM react-like diff --git a/packages/template/src/lib/stack-app/index.ts b/packages/template/src/lib/stack-app/index.ts index 84dbeaa9c..c6e206f9f 100644 --- a/packages/template/src/lib/stack-app/index.ts +++ b/packages/template/src/lib/stack-app/index.ts @@ -52,10 +52,10 @@ export type { AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, - AdminUserPermission, - AdminUserPermissionDefinition, - AdminUserPermissionDefinitionCreateOptions, - AdminUserPermissionDefinitionUpdateOptions, + AdminProjectPermission, + AdminProjectPermissionDefinition, + AdminProjectPermissionDefinitionCreateOptions, + AdminProjectPermissionDefinitionUpdateOptions, } from "./permissions"; export type { diff --git a/packages/template/src/lib/stack-app/permissions/index.ts b/packages/template/src/lib/stack-app/permissions/index.ts index 12f6a012b..c6dd154f1 100644 --- a/packages/template/src/lib/stack-app/permissions/index.ts +++ b/packages/template/src/lib/stack-app/permissions/index.ts @@ -1,5 +1,5 @@ import { TeamPermissionDefinitionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions"; -import { UserPermissionDefinitionsCrud } from "@stackframe/stack-shared/dist/interface/crud/user-permissions"; +import { ProjectPermissionDefinitionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; export type TeamPermission = { @@ -38,24 +38,24 @@ export function adminTeamPermissionDefinitionUpdateOptionsToCrud(options: AdminT }; } -export type UserPermission = { +export type ProjectPermission = { id: string, }; -export type AdminUserPermission = UserPermission; +export type AdminProjectPermission = ProjectPermission; -export type AdminUserPermissionDefinition = { +export type AdminProjectPermissionDefinition = { id: string, description?: string, containedPermissionIds: string[], }; -export type AdminUserPermissionDefinitionCreateOptions = { +export type AdminProjectPermissionDefinitionCreateOptions = { id: string, description?: string, containedPermissionIds: string[], }; -export function adminUserPermissionDefinitionCreateOptionsToCrud(options: AdminUserPermissionDefinitionCreateOptions): UserPermissionDefinitionsCrud["Admin"]["Create"] { +export function adminProjectPermissionDefinitionCreateOptionsToCrud(options: AdminProjectPermissionDefinitionCreateOptions): ProjectPermissionDefinitionsCrud["Admin"]["Create"] { return { id: options.id, description: options.description, @@ -63,8 +63,8 @@ export function adminUserPermissionDefinitionCreateOptionsToCrud(options: AdminU }; } -export type AdminUserPermissionDefinitionUpdateOptions = Partial; -export function adminUserPermissionDefinitionUpdateOptionsToCrud(options: AdminUserPermissionDefinitionUpdateOptions): UserPermissionDefinitionsCrud["Admin"]["Update"] { +export type AdminProjectPermissionDefinitionUpdateOptions = Partial; +export function adminProjectPermissionDefinitionUpdateOptionsToCrud(options: AdminProjectPermissionDefinitionUpdateOptions): ProjectPermissionDefinitionsCrud["Admin"]["Update"] { return { id: options.id, description: options.description, diff --git a/packages/template/src/lib/stack-app/users/index.ts b/packages/template/src/lib/stack-app/users/index.ts index fa32f1585..f50bf7030 100644 --- a/packages/template/src/lib/stack-app/users/index.ts +++ b/packages/template/src/lib/stack-app/users/index.ts @@ -187,6 +187,21 @@ export type UserExtra = { // END_PLATFORM hasPermission(scope: Team, permissionId: string): Promise, + hasPermission(permissionId: string): Promise, + + getPermission(scope: Team, permissionId: string): Promise, + getPermission(permissionId: string): Promise, + + listPermissions(scope: Team, options?: { recursive?: boolean }): Promise, + listPermissions(options?: { recursive?: boolean }): Promise, + + // IF_PLATFORM react-like + usePermissions(scope: Team, options?: { recursive?: boolean }): TeamPermission[], + usePermissions(options?: { recursive?: boolean }): TeamPermission[], + + usePermission(scope: Team, permissionId: string): TeamPermission | null, + usePermission(permissionId: string): TeamPermission | null, + // END_PLATFORM readonly selectedTeam: Team | null, setSelectedTeam(team: Team | null): Promise, @@ -270,6 +285,23 @@ export type ServerBaseUser = { grantPermission(scope: Team, permissionId: string): Promise, revokePermission(scope: Team, permissionId: string): Promise, + getPermission(scope: Team, permissionId: string): Promise, + getPermission(permissionId: string): Promise, + + hasPermission(scope: Team, permissionId: string): Promise, + hasPermission(permissionId: string): Promise, + + listPermissions(scope: Team, options?: { recursive?: boolean }): Promise, + listPermissions(options?: { recursive?: boolean }): Promise, + + // IF_PLATFORM react-like + usePermissions(scope: Team, options?: { recursive?: boolean }): TeamPermission[], + usePermissions(options?: { recursive?: boolean }): TeamPermission[], + + usePermission(scope: Team, permissionId: string): TeamPermission | null, + usePermission(permissionId: string): TeamPermission | null, + // END_PLATFORM + /** * Creates a new session object with a refresh token for this user. Can be used to impersonate them. */