diff --git a/apps/backend/prisma/migrations/20240618150845_system_team_permission/migration.sql b/apps/backend/prisma/migrations/20240618150845_system_team_permission/migration.sql new file mode 100644 index 0000000000..5ff9362199 --- /dev/null +++ b/apps/backend/prisma/migrations/20240618150845_system_team_permission/migration.sql @@ -0,0 +1,63 @@ +/* + Warnings: + + - The primary key for the `TeamMemberDirectPermission` table will be changed. If it partially fails, the table could be left without primary key constraint. + - A unique constraint covering the columns `[projectId,projectUserId,teamId,permissionDbId]` on the table `TeamMemberDirectPermission` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[projectId,projectUserId,teamId,systemPermission]` on the table `TeamMemberDirectPermission` will be added. If there are existing duplicate values, this will fail. + - The required column `id` was added to the `TeamMemberDirectPermission` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- CreateEnum +CREATE TYPE "TeamSystemPermission" AS ENUM ('UPDATE_TEAM', 'DELETE_TEAM', 'READ_MEMBERS', 'REMOVE_MEMBERS', 'INVITE_MEMBERS'); + +-- AlterTable +ALTER TABLE "Permission" ADD COLUMN "isDefaultTeamCreatorPermission" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isDefaultTeamMemberPermission" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "PermissionEdge" ADD COLUMN "parentTeamSystemPermission" "TeamSystemPermission", +ALTER COLUMN "parentPermissionDbId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "ProjectConfig" ADD COLUMN "teamCreateDefaultSystemPermissions" "TeamSystemPermission"[], +ADD COLUMN "teamMemberDefaultSystemPermissions" "TeamSystemPermission"[]; + + +-- -- AlterTable +-- ALTER TABLE "TeamMemberDirectPermission" DROP CONSTRAINT "TeamMemberDirectPermission_pkey", +-- ADD COLUMN "id" UUID NOT NULL, +-- ADD COLUMN "systemPermission" "TeamSystemPermission", +-- ALTER COLUMN "permissionDbId" DROP NOT NULL, +-- ADD CONSTRAINT "TeamMemberDirectPermission_pkey" PRIMARY KEY ("id"); + +-- -- CreateIndex +-- CREATE UNIQUE INDEX "TeamMemberDirectPermission_projectId_projectUserId_teamId_p_key" ON "TeamMemberDirectPermission"("projectId", "projectUserId", "teamId", "permissionDbId"); + +-- -- CreateIndex +-- CREATE UNIQUE INDEX "TeamMemberDirectPermission_projectId_projectUserId_teamId_s_key" ON "TeamMemberDirectPermission"("projectId", "projectUserId", "teamId", "systemPermission"); + + +-- Step 1: Add `id` as an optional column +ALTER TABLE "TeamMemberDirectPermission" +ADD COLUMN "id" UUID, +ADD COLUMN "systemPermission" "TeamSystemPermission"; + +-- Step 2: Populate the `id` column with UUID values +UPDATE "TeamMemberDirectPermission" SET "id" = gen_random_uuid(); + +-- Step 3: Make the `id` column required +ALTER TABLE "TeamMemberDirectPermission" ALTER COLUMN "id" SET NOT NULL; + +-- Step 4: Ensure there are no duplicate values for the unique constraints +-- There should be no duplicates for the unique constraints + +-- Step 5: Drop the existing primary key constraint +ALTER TABLE "TeamMemberDirectPermission" DROP CONSTRAINT "TeamMemberDirectPermission_pkey", +ALTER COLUMN "permissionDbId" DROP NOT NULL; + +-- Step 6: Add the unique constraints +CREATE UNIQUE INDEX "TeamMemberDirectPermission_projectId_projectUserId_teamId_p_key" ON "TeamMemberDirectPermission"("projectId", "projectUserId", "teamId", "permissionDbId"); +CREATE UNIQUE INDEX "TeamMemberDirectPermission_projectId_projectUserId_teamId_s_key" ON "TeamMemberDirectPermission"("projectId", "projectUserId", "teamId", "systemPermission"); + +-- Step 7: Add the new primary key constraint +ALTER TABLE "TeamMemberDirectPermission" ADD CONSTRAINT "TeamMemberDirectPermission_pkey" PRIMARY KEY ("id"); \ No newline at end of file diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index ae6b0f9d7a..6bfa006ba9 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -39,9 +39,10 @@ model ProjectConfig { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - allowLocalhost Boolean - credentialEnabled Boolean - magicLinkEnabled Boolean + allowLocalhost Boolean + credentialEnabled Boolean + magicLinkEnabled Boolean + createTeamOnSignUp Boolean projects Project[] @@ -49,6 +50,9 @@ model ProjectConfig { emailServiceConfig EmailServiceConfig? domains ProjectDomain[] permissions Permission[] + + teamCreateDefaultSystemPermissions TeamSystemPermission[] + teamMemberDefaultSystemPermissions TeamSystemPermission[] } model ProjectDomain { @@ -115,18 +119,23 @@ model TeamMember { } model TeamMemberDirectPermission { + id String @id @default(uuid()) @db.Uuid projectId String - projectUserId String @db.Uuid - teamId String @db.Uuid - permissionDbId String @db.Uuid + projectUserId String @db.Uuid + teamId String @db.Uuid + permissionDbId String? @db.Uuid createdAt DateTime @default(now()) updatedAt DateTime @updatedAt teamMember TeamMember @relation(fields: [projectId, projectUserId, teamId], references: [projectId, projectUserId, teamId], onDelete: Cascade) - permission Permission @relation(fields: [permissionDbId], references: [dbId], onDelete: Cascade) - @@id([projectId, projectUserId, teamId, permissionDbId]) + // exactly one of [permissionId && permission] or [systemPermission] must be set + permission Permission? @relation(fields: [permissionDbId], references: [dbId], onDelete: Cascade) + systemPermission TeamSystemPermission? + + @@unique([projectId, projectUserId, teamId, permissionDbId]) + @@unique([projectId, projectUserId, teamId, systemPermission]) } model Permission { @@ -153,6 +162,9 @@ model Permission { childEdges PermissionEdge[] @relation("ParentPermission") teamMemberDirectPermission TeamMemberDirectPermission[] + isDefaultTeamCreatorPermission Boolean @default(false) + isDefaultTeamMemberPermission Boolean @default(false) + @@unique([projectConfigId, queryableId]) @@unique([projectId, teamId, queryableId]) } @@ -162,16 +174,27 @@ enum PermissionScope { TEAM } +enum TeamSystemPermission { + UPDATE_TEAM + DELETE_TEAM + READ_MEMBERS + REMOVE_MEMBERS + INVITE_MEMBERS +} + model PermissionEdge { edgeId String @id @default(uuid()) @db.Uuid createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - parentPermissionDbId String @db.Uuid - parentPermission Permission @relation("ParentPermission", fields: [parentPermissionDbId], references: [dbId], onDelete: Cascade) - childPermissionDbId String @db.Uuid - childPermission Permission @relation("ChildPermission", fields: [childPermissionDbId], references: [dbId], onDelete: Cascade) + // exactly one of [parentPermissionDbId && parentPermission] or [parentTeamSystemPermission] must be set + parentPermissionDbId String? @db.Uuid + parentPermission Permission? @relation("ParentPermission", fields: [parentPermissionDbId], references: [dbId], onDelete: Cascade) + parentTeamSystemPermission TeamSystemPermission? + + childPermissionDbId String @db.Uuid + childPermission Permission @relation("ChildPermission", fields: [childPermissionDbId], references: [dbId], onDelete: Cascade) } model ProjectUser { diff --git a/apps/backend/src/lib/permissions.tsx b/apps/backend/src/lib/permissions.tsx new file mode 100644 index 0000000000..04a7789dda --- /dev/null +++ b/apps/backend/src/lib/permissions.tsx @@ -0,0 +1,666 @@ +import { prismaClient } from "@/prisma-client"; +import { Prisma, TeamSystemPermission as DBTeamSystemPermission } from "@prisma/client"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { PermissionDefinitionScopeJson } from "@stackframe/stack-shared/dist/interface/clientInterface"; +import { ServerPermissionDefinitionCustomizableJson, ServerPermissionDefinitionJson } from "@stackframe/stack-shared/dist/interface/serverInterface"; +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { typedToLowercase, typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; +import * as yup from "yup"; + +export const teamPermissionIdSchema = yup.string() + .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; + if (value.startsWith('$') && !isTeamSystemPermission(value)) { + return ctx.createError({ message: 'Invalid system permission' }); + } + return true; + }); + + +export const fullPermissionInclude = { + parentEdges: { + include: { + parentPermission: true, + }, + }, +} as const satisfies Prisma.PermissionInclude; + +export function isTeamSystemPermission(permission: string): permission is `$${Lowercase}` { + return permission.startsWith('$') && permission.slice(1).toUpperCase() in DBTeamSystemPermission; +} + +export function teamSystemPermissionStringToDBType(permission: `$${Lowercase}`): DBTeamSystemPermission { + return typedToUppercase(permission.slice(1)) as DBTeamSystemPermission; +} + +export function teamDBTypeToSystemPermissionString(permission: DBTeamSystemPermission): `$${Lowercase}` { + return '$' + typedToLowercase(permission) as `$${Lowercase}`; +} + +const teamSystemPermissionDescriptionMap: Record = { + "UPDATE_TEAM": "Update the team information", + "DELETE_TEAM": "Delete the team", + "READ_MEMBERS": "Read and list the other members of the team", + "REMOVE_MEMBERS": "Remove other members from the team", + "INVITE_MEMBERS": "Invite other users to the team", +}; + +function serverPermissionDefinitionJsonFromDbType( + db: Prisma.PermissionGetPayload<{ include: typeof fullPermissionInclude }> +): ServerPermissionDefinitionJson { + 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 === "GLOBAL" && db.teamId) throw new StackAssertionError(`Permission DB object should not have teamId when scope is GLOBAL`, { db }); + + return { + __databaseUniqueId: db.dbId, + id: db.queryableId, + scope: + db.scope === "GLOBAL" ? { type: "global" } : + db.teamId ? { type: "specific-team", teamId: db.teamId } : + db.projectConfigId ? { type: "any-team" } : + throwErr(new StackAssertionError(`Unexpected permission scope`, { db })), + description: db.description || undefined, + containPermissionIds: db.parentEdges.map((edge) => { + if (edge.parentPermission) { + return edge.parentPermission.queryableId; + } else if (edge.parentTeamSystemPermission) { + return '$' + typedToLowercase(edge.parentTeamSystemPermission); + } else { + throw new StackAssertionError(`Permission edge should have either parentPermission or parentSystemPermission`, { edge }); + } + }), + }; +} + +function serverPermissionDefinitionJsonFromTeamSystemDbType( + db: DBTeamSystemPermission, +): ServerPermissionDefinitionJson { + return { + __databaseUniqueId: '$' + typedToLowercase(db), + id: '$' + typedToLowercase(db), + scope: { type: "any-team" }, + description: teamSystemPermissionDescriptionMap[db], + containPermissionIds: [], + }; +} + +export async function listServerPermissionDefinitions(projectId: string, scope?: PermissionDefinitionScopeJson): Promise { + const results = []; + switch (scope?.type) { + case "specific-team": { + const team = await prismaClient.team.findUnique({ + where: { + projectId_teamId: { + projectId, + teamId: scope.teamId, + }, + }, + include: { + permissions: { + include: fullPermissionInclude, + }, + }, + }); + if (!team) throw new KnownErrors.TeamNotFound(scope.teamId); + results.push(...team.permissions.map(serverPermissionDefinitionJsonFromDbType)); + break; + } + case "global": + case "any-team": { + const res = await prismaClient.permission.findMany({ + where: { + projectConfig: { + projects: { + some: { + id: projectId, + } + } + }, + scope: scope.type === "global" ? "GLOBAL" : "TEAM", + }, + include: fullPermissionInclude, + }); + results.push(...res.map(serverPermissionDefinitionJsonFromDbType)); + break; + } + case undefined: { + const res = await prismaClient.permission.findMany({ + where: { + projectConfig: { + projects: { + some: { + id: projectId, + } + } + }, + }, + include: fullPermissionInclude, + }); + results.push(...res.map(serverPermissionDefinitionJsonFromDbType)); + } + } + + if (scope === undefined || scope.type === "any-team" || scope.type === "specific-team") { + for (const systemPermission of Object.values(DBTeamSystemPermission)) { + results.push(serverPermissionDefinitionJsonFromTeamSystemDbType(systemPermission)); + } + } + + return results; +} + +export async function grantTeamUserPermission({ + projectId, + teamId, + projectUserId, + type, + permissionId, +}: { + projectId: string, + teamId: string, + projectUserId: string, + type: "team" | "global", + permissionId: string, +}) { + const project = await prismaClient.project.findUnique({ + where: { + id: projectId, + }, + }); + + if (!project) throw new KnownErrors.ProjectNotFound(); + + switch (type) { + case "global": { + await prismaClient.teamMemberDirectPermission.upsert({ + where: { + projectId_projectUserId_teamId_permissionDbId: { + projectId, + projectUserId, + teamId, + permissionDbId: permissionId, + }, + }, + create: { + permission: { + connect: { + projectConfigId_queryableId: { + projectConfigId: project.configId, + queryableId: permissionId, + }, + }, + }, + teamMember: { + connect: { + projectId_projectUserId_teamId: { + projectId: projectId, + projectUserId: projectUserId, + teamId: teamId, + }, + }, + }, + }, + update: {}, + }); + break; + } + case "team": { + if (isTeamSystemPermission(permissionId)) { + await prismaClient.teamMemberDirectPermission.upsert({ + where: { + projectId_projectUserId_teamId_systemPermission: { + projectId, + projectUserId, + teamId, + systemPermission: teamSystemPermissionStringToDBType(permissionId), + }, + }, + create: { + systemPermission: teamSystemPermissionStringToDBType(permissionId), + teamMember: { + connect: { + projectId_projectUserId_teamId: { + projectId: projectId, + projectUserId: projectUserId, + teamId: teamId, + }, + }, + }, + }, + update: {}, + }); + break; + } + + const teamSpecificPermission = await prismaClient.permission.findUnique({ + where: { + projectId_teamId_queryableId: { + projectId, + teamId, + queryableId: permissionId, + }, + } + }); + const anyTeamPermission = await prismaClient.permission.findUnique({ + where: { + projectConfigId_queryableId: { + projectConfigId: project.configId, + queryableId: permissionId, + }, + } + }); + + const permission = teamSpecificPermission || anyTeamPermission; + if (!permission) throw new KnownErrors.PermissionNotFound(permissionId); + + await prismaClient.teamMemberDirectPermission.upsert({ + where: { + projectId_projectUserId_teamId_permissionDbId: { + projectId, + projectUserId, + teamId, + permissionDbId: permission.dbId, + }, + }, + create: { + permission: { + connect: { + dbId: permission.dbId, + }, + }, + teamMember: { + connect: { + projectId_projectUserId_teamId: { + projectId: projectId, + projectUserId: projectUserId, + teamId: teamId, + }, + }, + }, + }, + update: {}, + }); + + break; + } + } +} + +export async function revokeTeamUserPermission({ + projectId, + teamId, + projectUserId, + type, + permissionId, +}: { + projectId: string, + teamId: string, + projectUserId: string, + type: "team" | "global", + permissionId: string, +}) { + const project = await prismaClient.project.findUnique({ + where: { + id: projectId, + }, + }); + + if (!project) throw new KnownErrors.ProjectNotFound(); + + switch (type) { + case "global": { + await prismaClient.teamMemberDirectPermission.deleteMany({ + where: { + permission: { + projectConfigId: project.configId, + queryableId: permissionId, + }, + teamMember: { + projectId, + projectUserId, + teamId, + }, + }, + }); + break; + } + case "team": { + if (isTeamSystemPermission(permissionId)) { + await prismaClient.teamMemberDirectPermission.deleteMany({ + where: { + systemPermission: teamSystemPermissionStringToDBType(permissionId), + teamMember: { + projectId, + projectUserId, + teamId, + }, + }, + }); + break; + } + + const teamSpecificPermission = await prismaClient.permission.findUnique({ + where: { + projectId_teamId_queryableId: { + projectId, + teamId, + queryableId: permissionId, + }, + } + }); + const anyTeamPermission = await prismaClient.permission.findUnique({ + where: { + projectConfigId_queryableId: { + projectConfigId: project.configId, + queryableId: permissionId, + }, + } + }); + + const permission = teamSpecificPermission || anyTeamPermission; + if (!permission) throw new KnownErrors.PermissionNotFound(permissionId); + + await prismaClient.teamMemberDirectPermission.deleteMany({ + where: { + permissionDbId: permission.dbId, + teamMember: { + projectId, + projectUserId, + teamId, + }, + }, + }); + + break; + } + } +} + +export async function listUserPermissionDefinitionsRecursive({ + projectId, + teamId, + userId, + type, +}: { + projectId: string, + teamId: string, + userId: string, + type: 'team' | 'global', +}): Promise { + const allPermissions = []; + if (type === 'team') { + allPermissions.push(...await listServerPermissionDefinitions(projectId, { type: "specific-team", teamId })); + allPermissions.push(...await listServerPermissionDefinitions(projectId, { type: "any-team" })); + } else { + allPermissions.push(...await listServerPermissionDefinitions(projectId, { type: "global" })); + } + const permissionsMap = new Map(allPermissions.map(p => [p.id, p])); + + const user = await prismaClient.teamMember.findUnique({ + where: { + projectId_projectUserId_teamId: { + projectId, + projectUserId: userId, + teamId, + }, + }, + include: { + directPermissions: { + include: { + permission: true, + } + } + }, + }); + + if (!user) throw new KnownErrors.UserNotFound(); + + const result = new Map(); + const idsToProcess = [...user.directPermissions.map(p => + p.permission?.queryableId || + (p.systemPermission ? teamDBTypeToSystemPermissionString(p.systemPermission) : null) || + throwErr(new StackAssertionError(`Permission should have either queryableId or systemPermission`, { p })) + )]; + while (idsToProcess.length > 0) { + const currentId = idsToProcess.pop()!; + const current = permissionsMap.get(currentId); + if (!current) throw new StackAssertionError(`Couldn't find permission in DB`, { currentId, result, idsToProcess }); + if (result.has(current.id)) continue; + result.set(current.id, current); + idsToProcess.push(...current.containPermissionIds); + } + return [...result.values()]; +} + +export async function listUserDirectPermissions({ + projectId, + teamId, + userId, + type, +}: { + projectId: string, + teamId: string, + userId: string, + type: 'team' | 'global', +}): Promise { + const user = await prismaClient.teamMember.findUnique({ + where: { + projectId_projectUserId_teamId: { + projectId, + projectUserId: userId, + teamId, + }, + }, + include: { + directPermissions: { + include: { + permission: { + include: fullPermissionInclude, + } + } + } + }, + }); + if (!user) throw new KnownErrors.UserNotFound(); + return user.directPermissions.map( + p => { + if (p.permission) { + return serverPermissionDefinitionJsonFromDbType(p.permission); + } else if (p.systemPermission) { + return serverPermissionDefinitionJsonFromTeamSystemDbType(p.systemPermission); + } else { + throw new StackAssertionError(`Permission should have either permission or systemPermission`, { p }); + } + } + ).filter( + p => { + switch (p.scope.type) { + case "global": { + return type === "global"; + } + case "any-team": + case "specific-team": { + return type === "team"; + } + } + } + ); +} + +export async function listPotentialParentPermissions(projectId: string, scope: PermissionDefinitionScopeJson): Promise { + if (scope.type === "global") { + return await listServerPermissionDefinitions(projectId, { type: "global" }); + } else { + const scopes: PermissionDefinitionScopeJson[] = [ + { type: "any-team" }, + ...scope.type === "any-team" ? [] : [ + { type: "specific-team", teamId: scope.teamId } as const, + ], + ]; + + const permissions = (await Promise.all(scopes.map(s => listServerPermissionDefinitions(projectId, s))).then(res => res.flat(1))); + const systemPermissions = Object.values(DBTeamSystemPermission).map(serverPermissionDefinitionJsonFromTeamSystemDbType); + return [...permissions, ...systemPermissions]; + } +} + +export async function createPermissionDefinition( + projectId: string, + scope: PermissionDefinitionScopeJson, + permission: ServerPermissionDefinitionCustomizableJson +): Promise { + const project = await prismaClient.project.findUnique({ + where: { + id: projectId, + }, + }); + if (!project) throw new KnownErrors.ProjectNotFound(); + + let parentDbIds = []; + const potentialParentPermissions = await listPotentialParentPermissions(projectId, scope); + for (const parentPermissionId of permission.containPermissionIds) { + const parentPermission = potentialParentPermissions.find(p => p.id === parentPermissionId); + if (!parentPermission) throw new KnownErrors.PermissionNotFound(parentPermissionId); + parentDbIds.push(parentPermission.__databaseUniqueId); + } + const dbPermission = await prismaClient.permission.create({ + data: { + scope: scope.type === "global" ? "GLOBAL" : "TEAM", + queryableId: permission.id, + description: permission.description, + ...scope.type === "specific-team" ? { + projectId: project.id, + teamId: scope.teamId, + } : { + projectConfigId: project.configId, + }, + parentEdges: { + create: parentDbIds.map(parentDbId => { + if (isTeamSystemPermission(parentDbId)) { + return { + parentTeamSystemPermission: teamSystemPermissionStringToDBType(parentDbId), + }; + } else { + return { + parentPermission: { + connect: { + dbId: parentDbId, + }, + }, + }; + } + }) + }, + }, + include: fullPermissionInclude, + }); + return serverPermissionDefinitionJsonFromDbType(dbPermission); +} + +export async function updatePermissionDefinitions( + projectId: string, + scope: PermissionDefinitionScopeJson, + permissionId: string, + permission: Partial +): Promise { + const project = await prismaClient.project.findUnique({ + where: { + id: projectId, + }, + }); + if (!project) throw new KnownErrors.ProjectNotFound(); + + let parentDbIds: string[] = []; + if (permission.containPermissionIds) { + const potentialParentPermissions = await listPotentialParentPermissions(projectId, scope); + for (const parentPermissionId of permission.containPermissionIds) { + const parentPermission = potentialParentPermissions.find(p => p.id === parentPermissionId); + if (!parentPermission) throw new KnownErrors.PermissionNotFound(parentPermissionId); + parentDbIds.push(parentPermission.__databaseUniqueId); + } + } + + let edgeUpdateData = {}; + if (permission.containPermissionIds) { + edgeUpdateData = { + parentEdges: { + deleteMany: {}, + create: parentDbIds.map(parentDbId => { + if (isTeamSystemPermission(parentDbId)) { + return { + parentTeamSystemPermission: teamSystemPermissionStringToDBType(parentDbId), + }; + } else { + return { + parentPermission: { + connect: { + dbId: parentDbId, + }, + }, + }; + } + }), + }, + }; + } + + const dbPermission = await prismaClient.permission.update({ + where: { + projectConfigId_queryableId: { + projectConfigId: project.configId, + queryableId: permissionId, + }, + }, + data: { + queryableId: permission.id, + description: permission.description, + ...edgeUpdateData, + }, + include: fullPermissionInclude, + }); + return serverPermissionDefinitionJsonFromDbType(dbPermission); +} + +export async function deletePermissionDefinition(projectId: string, scope: PermissionDefinitionScopeJson, permissionId: string) { + switch (scope.type) { + case "global": + case "any-team": { + const project = await prismaClient.project.findUnique({ + where: { + id: projectId, + }, + }); + if (!project) throw new KnownErrors.ProjectNotFound(); + const deleted = await prismaClient.permission.deleteMany({ + where: { + projectConfigId: project.configId, + queryableId: permissionId, + }, + }); + if (deleted.count < 1) throw new KnownErrors.PermissionNotFound(permissionId); + break; + } + case "specific-team": { + const team = await prismaClient.team.findUnique({ + where: { + projectId_teamId: { + projectId, + teamId: scope.teamId, + }, + }, + }); + if (!team) throw new KnownErrors.TeamNotFound(scope.teamId); + const deleted = await prismaClient.permission.deleteMany({ + where: { + projectId, + queryableId: permissionId, + teamId: scope.teamId, + }, + }); + if (deleted.count < 1) throw new KnownErrors.PermissionNotFound(permissionId); + break; + } + } +} diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 4ed3d49bf3..9754f1c58d 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -1,5 +1,3 @@ -// TODO remove and replace with CRUD handler - import * as yup from "yup"; import { KnownErrors, OAuthProviderConfigJson, ProjectJson, ServerUserJson } from "@stackframe/stack-shared"; import { Prisma, ProxiedOAuthProviderType, StandardOAuthProviderType } from "@prisma/client"; @@ -10,6 +8,7 @@ import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { EmailConfigJson, SharedProvider, StandardProvider, sharedProviders, standardProviders } from "@stackframe/stack-shared/dist/interface/clientInterface"; import { OAuthProviderUpdateOptions, ProjectUpdateOptions } from "@stackframe/stack-shared/dist/interface/adminInterface"; import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { isTeamSystemPermission, listServerPermissionDefinitions, teamDBTypeToSystemPermissionString, teamPermissionIdSchema, teamSystemPermissionStringToDBType } from "./permissions"; function toDBSharedProvider(type: SharedProvider): ProxiedOAuthProviderType { @@ -68,6 +67,7 @@ export const fullProjectInclude = { standardEmailServiceConfig: true, }, }, + permissions: true, domains: true, }, }, @@ -90,6 +90,9 @@ export type ProjectDB = Prisma.ProjectGetPayload<{ include: FullProjectInclude } domains: Prisma.ProjectDomainGetPayload< typeof fullProjectInclude.config.include.domains >[], + permissions: Prisma.PermissionGetPayload< + typeof fullProjectInclude.config.include.permissions + >[], }, }; @@ -192,6 +195,38 @@ export async function createProject( include: fullProjectInclude, }); + await tx.permission.create({ + data: { + projectId: project.id, + projectConfigId: project.config.id, + queryableId: "member", + description: "Default permission for team members", + scope: 'TEAM', + parentEdges: { + createMany: { + data: (['READ_MEMBERS', 'INVITE_MEMBERS'] as const).map(p => ({ parentTeamSystemPermission: p })), + }, + }, + isDefaultTeamMemberPermission: true, + }, + }); + + await tx.permission.create({ + data: { + projectId: project.id, + projectConfigId: project.config.id, + queryableId: "admin", + description: "Default permission for team creators", + scope: 'TEAM', + parentEdges: { + createMany: { + data: (['UPDATE_TEAM', 'DELETE_TEAM', 'READ_MEMBERS', 'REMOVE_MEMBERS', 'INVITE_MEMBERS'] as const).map(p =>({ parentTeamSystemPermission: p })) + }, + }, + isDefaultTeamCreatorPermission: true, + }, + }); + const projectUserTx = await tx.projectUser.findUniqueOrThrow({ where: { projectId_projectUserId: { @@ -430,6 +465,68 @@ async function _createEmailConfigUpdateTransactions( return transactions; } +async function _createDefaultPermissionsUpdateTransactions( + projectId: string, + options: ProjectUpdateOptions +) { + const project = await prismaClient.project.findUnique({ + where: { id: projectId }, + include: fullProjectInclude, + }); + + if (!project) { + throw new Error(`Project with id '${projectId}' not found`); + } + + const transactions = []; + const permissions = await listServerPermissionDefinitions(projectId, { type: 'any-team' }); + + const params = [ + { + optionName: 'teamCreatorDefaultPermissionIds', + dbName: 'teamCreatorDefaultPermissions', + dbSystemName: 'teamCreateDefaultSystemPermissions', + }, + { + optionName: 'teamMemberDefaultPermissionIds', + dbName: 'teamMemberDefaultPermissions', + dbSystemName: 'teamMemberDefaultSystemPermissions', + }, + ] as const; + + for (const param of params) { + const creatorPerms = options.config?.[param.optionName]; + if (creatorPerms) { + if (!creatorPerms.every((id) => permissions.some((perm) => perm.id === id))) { + throw new StatusError(StatusError.BadRequest, "Invalid team default permission ids"); + } + + const connect = creatorPerms + .filter(x => !isTeamSystemPermission(x)) + .map((id) => ({ + projectConfigId_queryableId: { + projectConfigId: project.config.id, + queryableId: id + }, + })); + + const systemPerms = creatorPerms + .filter(isTeamSystemPermission) + .map(teamSystemPermissionStringToDBType); + + transactions.push(prismaClient.projectConfig.update({ + where: { id: project.config.id }, + data: { + [param.dbName]: { connect }, + [param.dbSystemName]: systemPerms, + }, + })); + } + } + + return transactions; +} + export async function updateProject( projectId: string, options: ProjectUpdateOptions, @@ -468,6 +565,7 @@ export async function updateProject( transaction.push(...(await _createOAuthConfigUpdateTransactions(projectId, options))); transaction.push(...(await _createEmailConfigUpdateTransactions(projectId, options))); + transaction.push(...(await _createDefaultPermissionsUpdateTransactions(projectId, options))); transaction.push(prismaClient.projectConfig.update({ where: { id: project.config.id }, @@ -562,6 +660,12 @@ export function projectJsonFromDbType(project: ProjectDB): ProjectJson { return []; }), emailConfig, + teamCreatorDefaultPermissionIds: project.config.permissions.filter(perm => perm.isDefaultTeamCreatorPermission) + .map((perm) => perm.queryableId) + .concat(project.config.teamCreateDefaultSystemPermissions.map(teamDBTypeToSystemPermissionString)), + teamMemberDefaultPermissionIds: project.config.permissions.filter(perm => perm.isDefaultTeamMemberPermission) + .map((perm) => perm.queryableId) + .concat(project.config.teamMemberDefaultSystemPermissions.map(teamDBTypeToSystemPermissionString)), }, }; } @@ -608,6 +712,8 @@ const nonRequiredSchemas = { password: requiredWhenShared(yup.string()), senderEmail: requiredWhenShared(yup.string().email()), }).optional().default(undefined), + teamCreatorDefaultPermissionIds: yup.array(teamPermissionIdSchema.required()).optional().default(undefined), + teamMemberDefaultPermissionIds: yup.array(teamPermissionIdSchema.required()).optional().default(undefined), }).optional().default(undefined), }; @@ -673,6 +779,8 @@ export const projectSchemaToUpdateOptions = ( senderEmail: update.config.emailConfig.senderEmail!, } ), + teamCreatorDefaultPermissionIds: update.config.teamCreatorDefaultPermissionIds, + teamMemberDefaultPermissionIds: update.config.teamMemberDefaultPermissionIds, }, }; }; diff --git a/apps/dashboard/prisma/migrations/20240306152532_initial_migration/migration.sql b/apps/dashboard/prisma/migrations/20240306152532_initial_migration/migration.sql deleted file mode 100644 index 62bbf3f5ca..0000000000 --- a/apps/dashboard/prisma/migrations/20240306152532_initial_migration/migration.sql +++ /dev/null @@ -1,297 +0,0 @@ --- CreateEnum -CREATE TYPE "ProxiedOAuthProviderType" AS ENUM ('GITHUB', 'FACEBOOK', 'GOOGLE', 'MICROSOFT'); - --- CreateEnum -CREATE TYPE "StandardOAuthProviderType" AS ENUM ('GITHUB', 'FACEBOOK', 'GOOGLE', 'MICROSOFT'); - --- CreateTable -CREATE TABLE "Project" ( - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "displayName" TEXT NOT NULL, - "description" TEXT DEFAULT '', - "configId" UUID NOT NULL, - "isProductionMode" BOOLEAN NOT NULL, - - CONSTRAINT "Project_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ProjectConfig" ( - "id" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "allowLocalhost" BOOLEAN NOT NULL, - "credentialEnabled" BOOLEAN NOT NULL, - - CONSTRAINT "ProjectConfig_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ProjectDomain" ( - "projectConfigId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "domain" TEXT NOT NULL, - "handlerPath" TEXT NOT NULL -); - --- CreateTable -CREATE TABLE "ProjectConfigOverride" ( - "projectId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ProjectConfigOverride_pkey" PRIMARY KEY ("projectId") -); - --- CreateTable -CREATE TABLE "ProjectUser" ( - "projectId" TEXT NOT NULL, - "projectUserId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "primaryEmail" TEXT, - "primaryEmailVerified" BOOLEAN NOT NULL, - "profileImageUrl" TEXT, - "displayName" TEXT, - "passwordHash" TEXT, - "serverMetadata" JSONB, - "clientMetadata" JSONB, - - CONSTRAINT "ProjectUser_pkey" PRIMARY KEY ("projectId","projectUserId") -); - --- CreateTable -CREATE TABLE "ProjectUserOAuthAccount" ( - "projectId" TEXT NOT NULL, - "projectUserId" UUID NOT NULL, - "projectConfigId" UUID NOT NULL, - "oauthProviderConfigId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "email" TEXT, - "providerAccountId" TEXT NOT NULL, - "providerRefreshToken" TEXT, - - CONSTRAINT "ProjectUserOAuthAccount_pkey" PRIMARY KEY ("projectId","oauthProviderConfigId","providerAccountId") -); - --- CreateTable -CREATE TABLE "ProjectUserRefreshToken" ( - "projectId" TEXT NOT NULL, - "projectUserId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "refreshToken" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3), - - CONSTRAINT "ProjectUserRefreshToken_pkey" PRIMARY KEY ("projectId","refreshToken") -); - --- CreateTable -CREATE TABLE "ProjectUserAuthorizationCode" ( - "projectId" TEXT NOT NULL, - "projectUserId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "authorizationCode" TEXT NOT NULL, - "redirectUri" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "codeChallenge" TEXT NOT NULL, - "codeChallengeMethod" TEXT NOT NULL, - - CONSTRAINT "ProjectUserAuthorizationCode_pkey" PRIMARY KEY ("projectId","authorizationCode") -); - --- CreateTable -CREATE TABLE "ProjectUserEmailVerificationCode" ( - "projectId" TEXT NOT NULL, - "projectUserId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "code" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "usedAt" TIMESTAMP(3), - "redirectUrl" TEXT NOT NULL, - - CONSTRAINT "ProjectUserEmailVerificationCode_pkey" PRIMARY KEY ("projectId","code") -); - --- CreateTable -CREATE TABLE "ProjectUserPasswordResetCode" ( - "projectId" TEXT NOT NULL, - "projectUserId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "code" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "usedAt" TIMESTAMP(3), - "redirectUrl" TEXT NOT NULL, - - CONSTRAINT "ProjectUserPasswordResetCode_pkey" PRIMARY KEY ("projectId","code") -); - --- CreateTable -CREATE TABLE "ApiKeySet" ( - "projectId" TEXT NOT NULL, - "id" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "description" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "manuallyRevokedAt" TIMESTAMP(3), - "publishableClientKey" TEXT, - "secretServerKey" TEXT, - "superSecretAdminKey" TEXT, - - CONSTRAINT "ApiKeySet_pkey" PRIMARY KEY ("projectId","id") -); - --- CreateTable -CREATE TABLE "EmailServiceConfig" ( - "projectConfigId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "senderName" TEXT NOT NULL, - - CONSTRAINT "EmailServiceConfig_pkey" PRIMARY KEY ("projectConfigId") -); - --- CreateTable -CREATE TABLE "ProxiedEmailServiceConfig" ( - "projectConfigId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ProxiedEmailServiceConfig_pkey" PRIMARY KEY ("projectConfigId") -); - --- CreateTable -CREATE TABLE "StandardEmailServiceConfig" ( - "projectConfigId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "senderEmail" TEXT NOT NULL, - "host" TEXT NOT NULL, - "port" INTEGER NOT NULL, - "username" TEXT NOT NULL, - "password" TEXT NOT NULL, - - CONSTRAINT "StandardEmailServiceConfig_pkey" PRIMARY KEY ("projectConfigId") -); - --- CreateTable -CREATE TABLE "OAuthProviderConfig" ( - "projectConfigId" UUID NOT NULL, - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "enabled" BOOLEAN NOT NULL DEFAULT true, - - CONSTRAINT "OAuthProviderConfig_pkey" PRIMARY KEY ("projectConfigId","id") -); - --- CreateTable -CREATE TABLE "ProxiedOAuthProviderConfig" ( - "projectConfigId" UUID NOT NULL, - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "type" "ProxiedOAuthProviderType" NOT NULL, - - CONSTRAINT "ProxiedOAuthProviderConfig_pkey" PRIMARY KEY ("projectConfigId","id") -); - --- CreateTable -CREATE TABLE "StandardOAuthProviderConfig" ( - "projectConfigId" UUID NOT NULL, - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "type" "StandardOAuthProviderType" NOT NULL, - "tenantId" TEXT, - "clientId" TEXT NOT NULL, - "clientSecret" TEXT NOT NULL, - - CONSTRAINT "StandardOAuthProviderConfig_pkey" PRIMARY KEY ("projectConfigId","id") -); - --- CreateIndex -CREATE UNIQUE INDEX "ProjectDomain_projectConfigId_domain_key" ON "ProjectDomain"("projectConfigId", "domain"); - --- CreateIndex -CREATE UNIQUE INDEX "ProjectUserRefreshToken_refreshToken_key" ON "ProjectUserRefreshToken"("refreshToken"); - --- CreateIndex -CREATE UNIQUE INDEX "ProjectUserAuthorizationCode_authorizationCode_key" ON "ProjectUserAuthorizationCode"("authorizationCode"); - --- CreateIndex -CREATE UNIQUE INDEX "ProjectUserEmailVerificationCode_code_key" ON "ProjectUserEmailVerificationCode"("code"); - --- CreateIndex -CREATE UNIQUE INDEX "ProjectUserPasswordResetCode_code_key" ON "ProjectUserPasswordResetCode"("code"); - --- CreateIndex -CREATE UNIQUE INDEX "ApiKeySet_publishableClientKey_key" ON "ApiKeySet"("publishableClientKey"); - --- CreateIndex -CREATE UNIQUE INDEX "ApiKeySet_secretServerKey_key" ON "ApiKeySet"("secretServerKey"); - --- CreateIndex -CREATE UNIQUE INDEX "ApiKeySet_superSecretAdminKey_key" ON "ApiKeySet"("superSecretAdminKey"); - --- CreateIndex -CREATE UNIQUE INDEX "ProxiedOAuthProviderConfig_projectConfigId_type_key" ON "ProxiedOAuthProviderConfig"("projectConfigId", "type"); - --- AddForeignKey -ALTER TABLE "Project" ADD CONSTRAINT "Project_configId_fkey" FOREIGN KEY ("configId") REFERENCES "ProjectConfig"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectDomain" ADD CONSTRAINT "ProjectDomain_projectConfigId_fkey" FOREIGN KEY ("projectConfigId") REFERENCES "ProjectConfig"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectConfigOverride" ADD CONSTRAINT "ProjectConfigOverride_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectUser" ADD CONSTRAINT "ProjectUser_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectUserOAuthAccount" ADD CONSTRAINT "ProjectUserOAuthAccount_projectConfigId_oauthProviderConfi_fkey" FOREIGN KEY ("projectConfigId", "oauthProviderConfigId") REFERENCES "OAuthProviderConfig"("projectConfigId", "id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectUserOAuthAccount" ADD CONSTRAINT "ProjectUserOAuthAccount_projectId_projectUserId_fkey" FOREIGN KEY ("projectId", "projectUserId") REFERENCES "ProjectUser"("projectId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectUserRefreshToken" ADD CONSTRAINT "ProjectUserRefreshToken_projectId_projectUserId_fkey" FOREIGN KEY ("projectId", "projectUserId") REFERENCES "ProjectUser"("projectId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectUserAuthorizationCode" ADD CONSTRAINT "ProjectUserAuthorizationCode_projectId_projectUserId_fkey" FOREIGN KEY ("projectId", "projectUserId") REFERENCES "ProjectUser"("projectId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectUserEmailVerificationCode" ADD CONSTRAINT "ProjectUserEmailVerificationCode_projectId_projectUserId_fkey" FOREIGN KEY ("projectId", "projectUserId") REFERENCES "ProjectUser"("projectId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProjectUserPasswordResetCode" ADD CONSTRAINT "ProjectUserPasswordResetCode_projectId_projectUserId_fkey" FOREIGN KEY ("projectId", "projectUserId") REFERENCES "ProjectUser"("projectId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ApiKeySet" ADD CONSTRAINT "ApiKeySet_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "EmailServiceConfig" ADD CONSTRAINT "EmailServiceConfig_projectConfigId_fkey" FOREIGN KEY ("projectConfigId") REFERENCES "ProjectConfig"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProxiedEmailServiceConfig" ADD CONSTRAINT "ProxiedEmailServiceConfig_projectConfigId_fkey" FOREIGN KEY ("projectConfigId") REFERENCES "EmailServiceConfig"("projectConfigId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StandardEmailServiceConfig" ADD CONSTRAINT "StandardEmailServiceConfig_projectConfigId_fkey" FOREIGN KEY ("projectConfigId") REFERENCES "EmailServiceConfig"("projectConfigId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "OAuthProviderConfig" ADD CONSTRAINT "OAuthProviderConfig_projectConfigId_fkey" FOREIGN KEY ("projectConfigId") REFERENCES "ProjectConfig"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ProxiedOAuthProviderConfig" ADD CONSTRAINT "ProxiedOAuthProviderConfig_projectConfigId_id_fkey" FOREIGN KEY ("projectConfigId", "id") REFERENCES "OAuthProviderConfig"("projectConfigId", "id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "StandardOAuthProviderConfig" ADD CONSTRAINT "StandardOAuthProviderConfig_projectConfigId_id_fkey" FOREIGN KEY ("projectConfigId", "id") REFERENCES "OAuthProviderConfig"("projectConfigId", "id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/dashboard/prisma/migrations/20240313024014_authroization_code_new_user/migration.sql b/apps/dashboard/prisma/migrations/20240313024014_authroization_code_new_user/migration.sql deleted file mode 100644 index a2ab0444e8..0000000000 --- a/apps/dashboard/prisma/migrations/20240313024014_authroization_code_new_user/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "ProjectUserAuthorizationCode" ADD COLUMN "newUser" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/dashboard/prisma/migrations/20240418090527_magic_link/migration.sql b/apps/dashboard/prisma/migrations/20240418090527_magic_link/migration.sql deleted file mode 100644 index a1a9ecf4df..0000000000 --- a/apps/dashboard/prisma/migrations/20240418090527_magic_link/migration.sql +++ /dev/null @@ -1,27 +0,0 @@ --- AlterTable -ALTER TABLE "ProjectConfig" ADD COLUMN "magicLinkEnabled" BOOLEAN NOT NULL DEFAULT false; - --- AlterTable, authWithEmail default to true if password hash is set previously, otherwise false -ALTER TABLE "ProjectUser" ADD COLUMN "authWithEmail" BOOLEAN NOT NULL DEFAULT false; -UPDATE "ProjectUser" SET "authWithEmail" = true WHERE "passwordHash" IS NOT NULL; - --- CreateTable -CREATE TABLE "ProjectUserMagicLinkCode" ( - "projectId" TEXT NOT NULL, - "projectUserId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "code" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "usedAt" TIMESTAMP(3), - "redirectUrl" TEXT NOT NULL, - "newUser" BOOLEAN NOT NULL, - - CONSTRAINT "ProjectUserMagicLinkCode_pkey" PRIMARY KEY ("projectId","code") -); - --- CreateIndex -CREATE UNIQUE INDEX "ProjectUserMagicLinkCode_code_key" ON "ProjectUserMagicLinkCode"("code"); - --- AddForeignKey -ALTER TABLE "ProjectUserMagicLinkCode" ADD CONSTRAINT "ProjectUserMagicLinkCode_projectId_projectUserId_fkey" FOREIGN KEY ("projectId", "projectUserId") REFERENCES "ProjectUser"("projectId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/dashboard/prisma/migrations/20240507195652_team/migration.sql b/apps/dashboard/prisma/migrations/20240507195652_team/migration.sql deleted file mode 100644 index 0d5365c233..0000000000 --- a/apps/dashboard/prisma/migrations/20240507195652_team/migration.sql +++ /dev/null @@ -1,108 +0,0 @@ - --- CreateEnum -CREATE TYPE "PermissionScope" AS ENUM ('GLOBAL', 'TEAM'); - --- AlterTable -ALTER TABLE "ProjectConfig" ADD COLUMN "createTeamOnSignUp" BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE "ProjectConfig" ALTER COLUMN "createTeamOnSignUp" DROP DEFAULT; -ALTER TABLE "ProjectConfig" ALTER COLUMN "magicLinkEnabled" DROP DEFAULT; - - --- AlterTable -ALTER TABLE "ProjectUser" ALTER COLUMN "authWithEmail" DROP DEFAULT; - --- AlterTable -ALTER TABLE "ProjectUserAuthorizationCode" ALTER COLUMN "newUser" DROP DEFAULT; - --- CreateTable -CREATE TABLE "Team" ( - "projectId" TEXT NOT NULL, - "teamId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "displayName" TEXT NOT NULL, - - CONSTRAINT "Team_pkey" PRIMARY KEY ("projectId","teamId") -); - --- CreateTable -CREATE TABLE "TeamMember" ( - "projectId" TEXT NOT NULL, - "projectUserId" UUID NOT NULL, - "teamId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("projectId","projectUserId","teamId") -); - --- CreateTable -CREATE TABLE "TeamMemberDirectPermission" ( - "projectId" TEXT NOT NULL, - "projectUserId" UUID NOT NULL, - "teamId" UUID NOT NULL, - "permissionDbId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "TeamMemberDirectPermission_pkey" PRIMARY KEY ("projectId","projectUserId","teamId","permissionDbId") -); - --- CreateTable -CREATE TABLE "Permission" ( - "queryableId" TEXT NOT NULL, - "dbId" UUID NOT NULL, - "projectConfigId" UUID, - "projectId" TEXT, - "teamId" UUID, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "description" TEXT, - "scope" "PermissionScope" NOT NULL, - - CONSTRAINT "Permission_pkey" PRIMARY KEY ("dbId") -); - --- CreateTable -CREATE TABLE "PermissionEdge" ( - "edgeId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "parentPermissionDbId" UUID NOT NULL, - "childPermissionDbId" UUID NOT NULL, - - CONSTRAINT "PermissionEdge_pkey" PRIMARY KEY ("edgeId") -); - --- CreateIndex -CREATE UNIQUE INDEX "Permission_projectConfigId_queryableId_key" ON "Permission"("projectConfigId", "queryableId"); - --- CreateIndex -CREATE UNIQUE INDEX "Permission_projectId_teamId_queryableId_key" ON "Permission"("projectId", "teamId", "queryableId"); - --- AddForeignKey -ALTER TABLE "Team" ADD CONSTRAINT "Team_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_projectId_projectUserId_fkey" FOREIGN KEY ("projectId", "projectUserId") REFERENCES "ProjectUser"("projectId", "projectUserId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_projectId_teamId_fkey" FOREIGN KEY ("projectId", "teamId") REFERENCES "Team"("projectId", "teamId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TeamMemberDirectPermission" ADD CONSTRAINT "TeamMemberDirectPermission_projectId_projectUserId_teamId_fkey" FOREIGN KEY ("projectId", "projectUserId", "teamId") REFERENCES "TeamMember"("projectId", "projectUserId", "teamId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TeamMemberDirectPermission" ADD CONSTRAINT "TeamMemberDirectPermission_permissionDbId_fkey" FOREIGN KEY ("permissionDbId") REFERENCES "Permission"("dbId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Permission" ADD CONSTRAINT "Permission_projectConfigId_fkey" FOREIGN KEY ("projectConfigId") REFERENCES "ProjectConfig"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Permission" ADD CONSTRAINT "Permission_projectId_teamId_fkey" FOREIGN KEY ("projectId", "teamId") REFERENCES "Team"("projectId", "teamId") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "PermissionEdge" ADD CONSTRAINT "PermissionEdge_parentPermissionDbId_fkey" FOREIGN KEY ("parentPermissionDbId") REFERENCES "Permission"("dbId") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "PermissionEdge" ADD CONSTRAINT "PermissionEdge_childPermissionDbId_fkey" FOREIGN KEY ("childPermissionDbId") REFERENCES "Permission"("dbId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/dashboard/prisma/migrations/20240518151916_email_config/migration.sql b/apps/dashboard/prisma/migrations/20240518151916_email_config/migration.sql deleted file mode 100644 index 4209d68d7c..0000000000 --- a/apps/dashboard/prisma/migrations/20240518151916_email_config/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `senderName` on the `EmailServiceConfig` table. All the data in the column will be lost. - - Added the required column `senderName` to the `StandardEmailServiceConfig` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "EmailServiceConfig" DROP COLUMN "senderName"; - --- AlterTable -ALTER TABLE "StandardEmailServiceConfig" ADD COLUMN "senderName" TEXT NOT NULL; diff --git a/apps/dashboard/prisma/migrations/20240520152704_selected_team/migration.sql b/apps/dashboard/prisma/migrations/20240520152704_selected_team/migration.sql deleted file mode 100644 index 9d7a06a31a..0000000000 --- a/apps/dashboard/prisma/migrations/20240520152704_selected_team/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterTable -ALTER TABLE "ProjectUser" ADD COLUMN "selectedTeamId" UUID; - --- AddForeignKey -ALTER TABLE "ProjectUser" ADD CONSTRAINT "ProjectUser_projectId_selectedTeamId_fkey" FOREIGN KEY ("projectId", "selectedTeamId") REFERENCES "Team"("projectId", "teamId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/dashboard/prisma/migrations/20240528090210_email_templates/migration.sql b/apps/dashboard/prisma/migrations/20240528090210_email_templates/migration.sql deleted file mode 100644 index 5362c0f384..0000000000 --- a/apps/dashboard/prisma/migrations/20240528090210_email_templates/migration.sql +++ /dev/null @@ -1,17 +0,0 @@ --- CreateEnum -CREATE TYPE "EmailTemplateType" AS ENUM ('EMAIL_VERIFICATION', 'PASSWORD_RESET', 'MAGIC_LINK'); - --- CreateTable -CREATE TABLE "EmailTemplate" ( - "projectConfigId" UUID NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "content" JSONB NOT NULL, - "type" "EmailTemplateType" NOT NULL, - "subject" TEXT NOT NULL, - - CONSTRAINT "EmailTemplate_pkey" PRIMARY KEY ("projectConfigId","type") -); - --- AddForeignKey -ALTER TABLE "EmailTemplate" ADD CONSTRAINT "EmailTemplate_projectConfigId_fkey" FOREIGN KEY ("projectConfigId") REFERENCES "EmailServiceConfig"("projectConfigId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/dashboard/prisma/migrations/20240529121811_spotify_oauth/migration.sql b/apps/dashboard/prisma/migrations/20240529121811_spotify_oauth/migration.sql deleted file mode 100644 index f9aa768c34..0000000000 --- a/apps/dashboard/prisma/migrations/20240529121811_spotify_oauth/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- AlterEnum -ALTER TYPE "ProxiedOAuthProviderType" ADD VALUE 'SPOTIFY'; - --- AlterEnum -ALTER TYPE "StandardOAuthProviderType" ADD VALUE 'SPOTIFY'; diff --git a/apps/dashboard/prisma/migrations/20240608142105_oauth_access_token/migration.sql b/apps/dashboard/prisma/migrations/20240608142105_oauth_access_token/migration.sql deleted file mode 100644 index f0814bdbb1..0000000000 --- a/apps/dashboard/prisma/migrations/20240608142105_oauth_access_token/migration.sql +++ /dev/null @@ -1,32 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `providerRefreshToken` on the `ProjectUserOAuthAccount` table. All the data in the column will be lost. - - You are about to drop the column `tenantId` on the `StandardOAuthProviderConfig` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "ProjectUserAuthorizationCode" ADD COLUMN "afterCallbackRedirectUrl" TEXT; - --- AlterTable -ALTER TABLE "ProjectUserOAuthAccount" DROP COLUMN "providerRefreshToken"; - --- AlterTable -ALTER TABLE "StandardOAuthProviderConfig" DROP COLUMN "tenantId"; - --- CreateTable -CREATE TABLE "OAuthToken" ( - "id" UUID NOT NULL, - "projectId" TEXT NOT NULL, - "oAuthProviderConfigId" TEXT NOT NULL, - "providerAccountId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "refreshToken" TEXT NOT NULL, - "scopes" TEXT[], - - CONSTRAINT "OAuthToken_pkey" PRIMARY KEY ("id") -); - --- AddForeignKey -ALTER TABLE "OAuthToken" ADD CONSTRAINT "OAuthToken_projectId_oAuthProviderConfigId_providerAccount_fkey" FOREIGN KEY ("projectId", "oAuthProviderConfigId", "providerAccountId") REFERENCES "ProjectUserOAuthAccount"("projectId", "oauthProviderConfigId", "providerAccountId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/dashboard/prisma/migrations/20240610085756_outer_oauth_info/migration.sql b/apps/dashboard/prisma/migrations/20240610085756_outer_oauth_info/migration.sql deleted file mode 100644 index 50146c5bdf..0000000000 --- a/apps/dashboard/prisma/migrations/20240610085756_outer_oauth_info/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- CreateTable -CREATE TABLE "OAuthOuterInfo" ( - "id" UUID NOT NULL, - "info" JSONB NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "OAuthOuterInfo_pkey" PRIMARY KEY ("id") -); diff --git a/apps/dashboard/prisma/migrations/migration_lock.toml b/apps/dashboard/prisma/migrations/migration_lock.toml deleted file mode 100644 index fbffa92c2b..0000000000 --- a/apps/dashboard/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file diff --git a/apps/dashboard/prisma/schema.prisma b/apps/dashboard/prisma/schema.prisma index ae6b0f9d7a..6bfa006ba9 100644 --- a/apps/dashboard/prisma/schema.prisma +++ b/apps/dashboard/prisma/schema.prisma @@ -39,9 +39,10 @@ model ProjectConfig { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - allowLocalhost Boolean - credentialEnabled Boolean - magicLinkEnabled Boolean + allowLocalhost Boolean + credentialEnabled Boolean + magicLinkEnabled Boolean + createTeamOnSignUp Boolean projects Project[] @@ -49,6 +50,9 @@ model ProjectConfig { emailServiceConfig EmailServiceConfig? domains ProjectDomain[] permissions Permission[] + + teamCreateDefaultSystemPermissions TeamSystemPermission[] + teamMemberDefaultSystemPermissions TeamSystemPermission[] } model ProjectDomain { @@ -115,18 +119,23 @@ model TeamMember { } model TeamMemberDirectPermission { + id String @id @default(uuid()) @db.Uuid projectId String - projectUserId String @db.Uuid - teamId String @db.Uuid - permissionDbId String @db.Uuid + projectUserId String @db.Uuid + teamId String @db.Uuid + permissionDbId String? @db.Uuid createdAt DateTime @default(now()) updatedAt DateTime @updatedAt teamMember TeamMember @relation(fields: [projectId, projectUserId, teamId], references: [projectId, projectUserId, teamId], onDelete: Cascade) - permission Permission @relation(fields: [permissionDbId], references: [dbId], onDelete: Cascade) - @@id([projectId, projectUserId, teamId, permissionDbId]) + // exactly one of [permissionId && permission] or [systemPermission] must be set + permission Permission? @relation(fields: [permissionDbId], references: [dbId], onDelete: Cascade) + systemPermission TeamSystemPermission? + + @@unique([projectId, projectUserId, teamId, permissionDbId]) + @@unique([projectId, projectUserId, teamId, systemPermission]) } model Permission { @@ -153,6 +162,9 @@ model Permission { childEdges PermissionEdge[] @relation("ParentPermission") teamMemberDirectPermission TeamMemberDirectPermission[] + isDefaultTeamCreatorPermission Boolean @default(false) + isDefaultTeamMemberPermission Boolean @default(false) + @@unique([projectConfigId, queryableId]) @@unique([projectId, teamId, queryableId]) } @@ -162,16 +174,27 @@ enum PermissionScope { TEAM } +enum TeamSystemPermission { + UPDATE_TEAM + DELETE_TEAM + READ_MEMBERS + REMOVE_MEMBERS + INVITE_MEMBERS +} + model PermissionEdge { edgeId String @id @default(uuid()) @db.Uuid createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - parentPermissionDbId String @db.Uuid - parentPermission Permission @relation("ParentPermission", fields: [parentPermissionDbId], references: [dbId], onDelete: Cascade) - childPermissionDbId String @db.Uuid - childPermission Permission @relation("ChildPermission", fields: [childPermissionDbId], references: [dbId], onDelete: Cascade) + // exactly one of [parentPermissionDbId && parentPermission] or [parentTeamSystemPermission] must be set + parentPermissionDbId String? @db.Uuid + parentPermission Permission? @relation("ParentPermission", fields: [parentPermissionDbId], references: [dbId], onDelete: Cascade) + parentTeamSystemPermission TeamSystemPermission? + + childPermissionDbId String @db.Uuid + childPermission Permission @relation("ChildPermission", fields: [childPermissionDbId], references: [dbId], onDelete: Cascade) } model ProjectUser { diff --git a/apps/dashboard/prisma/seed.ts b/apps/dashboard/prisma/seed.ts deleted file mode 100644 index 06c2d5c721..0000000000 --- a/apps/dashboard/prisma/seed.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); - - -async function seed() { - console.log('Seeding database...'); - - const oldProjects = await prisma.project.findUnique({ - where: { - id: 'internal', - }, - }); - - if (oldProjects) { - console.log('Internal project already exists, skipping seeding'); - return; - } - - await prisma.project.upsert({ - where: { - id: 'internal', - }, - create: { - id: 'internal', - displayName: 'Stack Dashboard', - description: 'Stack\'s admin dashboard', - isProductionMode: false, - apiKeySets: { - create: [{ - description: "Internal API key set", - publishableClientKey: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY ?? require('crypto').randomBytes(8).toString("hex"), - secretServerKey: process.env.STACK_SECRET_SERVER_KEY ?? require('crypto').randomBytes(8).toString("hex"), - expiresAt: new Date('2099-12-31T23:59:59Z'), - }], - }, - config: { - create: { - allowLocalhost: true, - oauthProviderConfigs: { - create: (['github', 'facebook', 'google', 'microsoft'] as const).map((id) => ({ - id, - proxiedOAuthConfig: { - create: { - type: id.toUpperCase() as any, - } - }, - projectUserOAuthAccounts: { - create: [] - } - })), - }, - emailServiceConfig: { - create: { - proxiedEmailServiceConfig: { - create: {} - } - } - }, - credentialEnabled: true, - magicLinkEnabled: true, - createTeamOnSignUp: false, - }, - }, - }, - update: {}, - }); - console.log('Internal project created'); - console.log('Seeding complete!'); -} - -seed().catch(async (e) => { - console.error(e); - await prisma.$disconnect(); - process.exit(1); -// eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/return-await -}).finally(async () => await prisma.$disconnect()); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-permissions/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-permissions/page-client.tsx index abc145f66e..64b1220a16 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-permissions/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-permissions/page-client.tsx @@ -5,8 +5,7 @@ import { useAdminApp } from "../use-admin-app"; import { Button } from "@/components/ui/button"; import { PermissionListField } from "@/components/permission-field"; import { PageLayout } from "../page-layout"; -import { FormDialog, SmartFormDialog } from "@/components/form-dialog"; -import { InputField } from "@/components/form-fields"; +import { SmartFormDialog } from "@/components/form-dialog"; import { TeamPermissionTable } from "@/components/data-table/team-permission-table"; @@ -42,7 +41,10 @@ function CreateDialog(props: { const permissions = stackAdminApp.usePermissionDefinitions(); const formSchema = yup.object({ - id: yup.string().required().notOneOf(permissions.map((p) => p.id), "ID already exists").label("ID"), + id: yup.string().required() + .notOneOf(permissions.map((p) => p.id), "ID already exists") + .matches(/^[a-z0-9_:]+$/, 'Only lowercase letters, numbers, ":" and "_" are allowed') + .label("ID"), description: yup.string().label("Description"), containPermissionIds: yup.array().of(yup.string().required()).required().default([]).meta({ stackFormFieldRender: (props) => ( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx index 622ea3bba6..3424a26097 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/team-settings/page-client.tsx @@ -1,9 +1,62 @@ "use client"; import { useAdminApp } from "../use-admin-app"; import { PageLayout } from "../page-layout"; -import { SettingCard, SettingSwitch } from "@/components/settings"; +import { SettingCard, SettingSwitch, SettingText } from "@/components/settings"; import Typography from "@/components/ui/typography"; +import { SmartFormDialog } from "@/components/form-dialog"; +import { PermissionListField } from "@/components/permission-field"; +import * as yup from "yup"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +function CreateDialog(props: { + trigger: React.ReactNode, + type: "creator" | "member", +}) { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProjectAdmin(); + const permissions = stackAdminApp.usePermissionDefinitions(); + + const formSchema = yup.object({ + permissions: yup.array().of(yup.string().required()).required().default([]).meta({ + stackFormFieldRender: (props) => ( + + ), + }), + }).default({ + permissions: props.type === "creator" ? + project.evaluatedConfig.teamCreatorDefaultPermissionIds : + project.evaluatedConfig.teamMemberDefaultPermissionIds + }); + + return { + if (props.type === "creator") { + await project.update({ + config: { + teamCreatorDefaultPermissionIds: values.permissions, + }, + }); + } else { + await project.update({ + config: { + teamMemberDefaultPermissionIds: values.permissions, + }, + }); + } + }} + cancelButton + />; +} export default function PageClient() { const stackAdminApp = useAdminApp(); @@ -27,7 +80,39 @@ export default function PageClient() { When enabled, a personal team will be created for each user when they sign up. This will not automatically create teams for existing users. - + + {([ + { + type: 'creator', + title: "Team Creator Default Permissions", + description: "Permissions the user will automatically be granted when creating a team", + key: 'teamCreatorDefaultPermissionIds', + }, { + type: 'member', + title: "Team Member Default Permissions", + description: "Permissions the user will automatically be granted when joining a team", + key: 'teamMemberDefaultPermissionIds', + } + ] as const).map(({ type, title, description, key }) => ( + Edit} + type={type} + />} + > +
+ {project.evaluatedConfig[key].length > 0 ? + project.evaluatedConfig[key].map((permissionId) => ( + {permissionId} + )) : + No default permissions set + } +
+
+ ))} ); } diff --git a/apps/dashboard/src/app/api/v1/current-user/teams/route.tsx b/apps/dashboard/src/app/api/v1/current-user/teams/route.tsx index 835b618c9f..3201b09b89 100644 --- a/apps/dashboard/src/app/api/v1/current-user/teams/route.tsx +++ b/apps/dashboard/src/app/api/v1/current-user/teams/route.tsx @@ -5,7 +5,12 @@ import { deprecatedSmartRouteHandler } from "@/route-handlers/smart-route-handle import { deprecatedParseRequest } from "@/route-handlers/smart-request"; import { authorizationHeaderSchema, decodeAccessToken } from "@/lib/tokens"; import { checkApiKeySet, publishableClientKeyHeaderSchema, secretServerKeyHeaderSchema } from "@/lib/api-keys"; -import { listUserServerTeams, listUserTeams } from "@/lib/teams"; +import { + createServerTeamForUser, + createTeamForUser, + listUserServerTeams, + listUserTeams, +} from "@/lib/teams"; import { ServerTeamJson } from "@stackframe/stack-shared/dist/interface/serverInterface"; import { TeamJson } from "@stackframe/stack-shared/dist/interface/clientInterface"; @@ -69,3 +74,66 @@ export const GET = deprecatedSmartRouteHandler(async (req: NextRequest) => { return NextResponse.json(teams satisfies TeamJson[]); } }); + + +const postSchema = yup.object({ + query: yup.object({ + server: yup.string().oneOf(["true", "false"]).required(), + }).required(), + headers: yup.object({ + authorization: authorizationHeaderSchema.optional(), + "x-stack-publishable-client-key": publishableClientKeyHeaderSchema.default(""), + "x-stack-secret-server-key": secretServerKeyHeaderSchema.default(""), + "x-stack-project-id": yup.string().required(), + }).required(), + body: yup.object({ + displayName: yup.string().required(), + }).required(), +}); + +export const POST = deprecatedSmartRouteHandler(async (req: NextRequest) => { + const { + query: { + server, + }, + headers: { + authorization, + "x-stack-publishable-client-key": publishableClientKey, + "x-stack-project-id": projectId, + "x-stack-secret-server-key": secretServerKey, + }, + body, + } = await deprecatedParseRequest(req, postSchema); + + if (!authorization) { + return NextResponse.json(null); + } + + const skValid = await checkApiKeySet(projectId, { secretServerKey }); + const pkValid = await checkApiKeySet(projectId, { publishableClientKey }); + + if (!pkValid && !skValid) { + throw new StatusError(StatusError.Forbidden); + } + + const decodedAccessToken = await decodeAccessToken(authorization.split(" ")[1]); + const { userId, projectId: accessTokenProjectId } = decodedAccessToken; + + if (accessTokenProjectId !== projectId) { + throw new StatusError(StatusError.NotFound); + } + + if (server === "true") { + if (!skValid) { + throw new StatusError(StatusError.Forbidden, "Secret server key is invalid"); + } + const team = await createTeamForUser({ projectId, userId, data: body }); + return NextResponse.json(team); + } else { + if (!pkValid) { + throw new StatusError(StatusError.Forbidden, "Publishable client key is invalid"); + } + const team = await createServerTeamForUser({ projectId, userId, data: body }); + return NextResponse.json(team); + } +}); diff --git a/apps/dashboard/src/app/api/v1/permission-definitions/[permId]/route.tsx b/apps/dashboard/src/app/api/v1/permission-definitions/[permId]/route.tsx index 9877383c9d..35584fae70 100644 --- a/apps/dashboard/src/app/api/v1/permission-definitions/[permId]/route.tsx +++ b/apps/dashboard/src/app/api/v1/permission-definitions/[permId]/route.tsx @@ -5,7 +5,7 @@ import { deprecatedSmartRouteHandler } from "@/route-handlers/smart-route-handle import { deprecatedParseRequest } from "@/route-handlers/smart-request"; import { checkApiKeySet, secretServerKeyHeaderSchema } from "@/lib/api-keys"; import { isProjectAdmin } from "@/lib/projects"; -import { deletePermissionDefinition, updatePermissionDefinitions } from "@/lib/permissions"; +import { deletePermissionDefinition, teamPermissionIdSchema, updatePermissionDefinitions } from "@/lib/permissions"; const putSchema = yup.object({ query: yup.object({ @@ -17,7 +17,7 @@ const putSchema = yup.object({ "x-stack-project-id": yup.string().required(), }).required(), body: yup.object({ - id: yup.string(), + id: teamPermissionIdSchema, description: yup.string(), containPermissionIds: yup.array(yup.string().required()), }).required(), diff --git a/apps/dashboard/src/app/api/v1/permission-definitions/route.tsx b/apps/dashboard/src/app/api/v1/permission-definitions/route.tsx index 92376ea41c..21b8b8eae6 100644 --- a/apps/dashboard/src/app/api/v1/permission-definitions/route.tsx +++ b/apps/dashboard/src/app/api/v1/permission-definitions/route.tsx @@ -5,7 +5,7 @@ import { deprecatedSmartRouteHandler } from "@/route-handlers/smart-route-handle import { deprecatedParseRequest } from "@/route-handlers/smart-request"; import { checkApiKeySet, secretServerKeyHeaderSchema } from "@/lib/api-keys"; import { isProjectAdmin } from "@/lib/projects"; -import { createPermissionDefinition, listServerPermissionDefinitions } from "@/lib/permissions"; +import { createPermissionDefinition, listServerPermissionDefinitions, teamPermissionIdSchema } from "@/lib/permissions"; import { KnownErrors } from "@stackframe/stack-shared"; const getSchema = yup.object({ @@ -58,7 +58,7 @@ const postSchema = yup.object({ "x-stack-project-id": yup.string().required(), }).required(), body: yup.object({ - id: yup.string().required(), + id: teamPermissionIdSchema.required(), description: yup.string(), scope: yup.object({ type: yup.string().oneOf(["any-team"]).required(), diff --git a/apps/dashboard/src/app/api/v1/teams/[teamId]/users/[userId]/route.tsx b/apps/dashboard/src/app/api/v1/teams/[teamId]/users/[userId]/route.tsx index 0ba55cfac6..c02f37456c 100644 --- a/apps/dashboard/src/app/api/v1/teams/[teamId]/users/[userId]/route.tsx +++ b/apps/dashboard/src/app/api/v1/teams/[teamId]/users/[userId]/route.tsx @@ -4,7 +4,7 @@ import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { deprecatedSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { deprecatedParseRequest } from "@/route-handlers/smart-request"; import { checkApiKeySet, secretServerKeyHeaderSchema } from "@/lib/api-keys"; -import { addUserToTeam, getTeam, listUserTeams, removeUserFromTeam } from "@/lib/teams"; +import { addUserToTeam, getTeam, grantDefaultTeamMemberPermissions, listUserTeams, removeUserFromTeam } from "@/lib/teams"; import { getClientUser } from "@/lib/users"; import { isProjectAdmin } from "@/lib/projects"; @@ -53,7 +53,8 @@ export const POST = deprecatedSmartRouteHandler(async (req: NextRequest, options throw new StatusError(StatusError.BadRequest, "User is already in the team"); } - await addUserToTeam(projectId, options.params.teamId, options.params.userId); + await addUserToTeam({ projectId, teamId: options.params.teamId, userId: options.params.userId }); + await grantDefaultTeamMemberPermissions({ projectId, teamId: options.params.teamId, userId: options.params.userId }); } return NextResponse.json(null); @@ -92,7 +93,7 @@ export const DELETE = deprecatedSmartRouteHandler(async (req: NextRequest, optio throw new StatusError(StatusError.Forbidden); } - await removeUserFromTeam(projectId, options.params.teamId, options.params.userId); + await removeUserFromTeam({ projectId, teamId: options.params.teamId, userId: options.params.userId }); } return NextResponse.json(null); diff --git a/apps/dashboard/src/app/api/v1/teams/route.tsx b/apps/dashboard/src/app/api/v1/teams/route.tsx index 772207474e..7bc8baa053 100644 --- a/apps/dashboard/src/app/api/v1/teams/route.tsx +++ b/apps/dashboard/src/app/api/v1/teams/route.tsx @@ -11,7 +11,7 @@ import { KnownErrors } from "@stackframe/stack-shared"; const getSchema = yup.object({ query: yup.object({ - server: yup.string().oneOf(["true"]).required(), + server: yup.string().oneOf(["true", "false"]).required(), }).required(), headers: yup.object({ "x-stack-secret-server-key": secretServerKeyHeaderSchema.default(""), @@ -42,8 +42,7 @@ export const GET = deprecatedSmartRouteHandler(async (req: NextRequest) => { throw new KnownErrors.ApiKeyNotFound(); } teams = await listServerTeams(projectId); - } - + } return NextResponse.json(teams); }); diff --git a/apps/dashboard/src/app/api/v1/users/[userId]/teams/route.tsx b/apps/dashboard/src/app/api/v1/users/[userId]/teams/route.tsx new file mode 100644 index 0000000000..95d74d4694 --- /dev/null +++ b/apps/dashboard/src/app/api/v1/users/[userId]/teams/route.tsx @@ -0,0 +1,57 @@ +import { checkApiKeySet, secretServerKeyHeaderSchema } from "@/lib/api-keys"; +import { isProjectAdmin } from "@/lib/projects"; +import { createServerTeamForUser } from "@/lib/teams"; +import { getServerUser } from "@/lib/users"; +import { deprecatedParseRequest } from "@/route-handlers/smart-request"; +import { deprecatedSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { NextRequest, NextResponse } from "next/server"; +import * as yup from "yup"; + +const postSchema = yup.object({ + query: yup.object({ + server: yup.string().oneOf(["true"]).required(), + }).required(), + headers: yup.object({ + "x-stack-secret-server-key": secretServerKeyHeaderSchema.default(""), + "x-stack-admin-access-token": yup.string().default(""), + "x-stack-project-id": yup.string().required(), + }).required(), + body: yup.object({ + displayName: yup.string().required(), + }).required(), +}); + +export const POST = deprecatedSmartRouteHandler(async (req: NextRequest, options: { params: { userId: string } }) => { + const { + query: { + server, + }, + headers: { + "x-stack-project-id": projectId, + "x-stack-secret-server-key": secretServerKey, + "x-stack-admin-access-token": adminAccessToken, + }, + body, + } = await deprecatedParseRequest(req, postSchema); + + const skValid = await checkApiKeySet(projectId, { secretServerKey }); + const asValid = await isProjectAdmin(projectId, adminAccessToken); + + // eslint-disable-next-line + if (server === "true") { + if (!skValid && !asValid) { + throw new StatusError(StatusError.Forbidden); + } + + const user = await getServerUser(projectId, options.params.userId); + if (!user) { + throw new StatusError(StatusError.NotFound, "User not found"); + } + + const team = await createServerTeamForUser({ projectId, userId: options.params.userId, data: body }); + return NextResponse.json(team); + } + + return NextResponse.json(null); +}); diff --git a/apps/dashboard/src/components/data-table/elements/cells.tsx b/apps/dashboard/src/components/data-table/elements/cells.tsx index fd7879b3b6..b1b822674d 100644 --- a/apps/dashboard/src/components/data-table/elements/cells.tsx +++ b/apps/dashboard/src/components/data-table/elements/cells.tsx @@ -1,6 +1,6 @@ 'use client'; -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Avatar, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, @@ -13,14 +13,39 @@ import { Button } from "@/components/ui/button"; import { DotsHorizontalIcon } from "@radix-ui/react-icons"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; - +import { SimpleTooltip } from "@/components/simple-tooltip"; export function TextCell(props: { children: React.ReactNode, size?: number, icon?: React.ReactNode }) { + const textRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const overflowStyle = "text-ellipsis text-nowrap overflow-x-hidden"; + + useEffect(() => { + const checkOverflow = () => { + if (textRef.current) { + const isOverflowing = textRef.current.scrollWidth > textRef.current.clientWidth; + setIsOverflowing(isOverflowing); + } + }; + + checkOverflow(); + window.addEventListener('resize', checkOverflow); + return () => { + window.removeEventListener('resize', checkOverflow); + }; + }, []); + return (
-
- {props.children} +
+ {isOverflowing ? ( + +
+ {props.children} +
+
+ ) : props.children}
{props.icon &&
{props.icon}
}
diff --git a/apps/dashboard/src/components/data-table/team-permission-table.tsx b/apps/dashboard/src/components/data-table/team-permission-table.tsx index ed809382d4..8d424e72ef 100644 --- a/apps/dashboard/src/components/data-table/team-permission-table.tsx +++ b/apps/dashboard/src/components/data-table/team-permission-table.tsx @@ -37,6 +37,7 @@ function EditDialog(props: { id: yup.string() .required() .notOneOf(permissions.map((p) => p.id).filter(p => p !== props.selectedPermissionId), "ID already exists") + .matches(/^[a-z0-9_:]+$/, 'Only lowercase letters, numbers, ":" and "_" are allowed') .label("ID"), description: yup.string().label("Description"), containPermissionIds: yup.array().of(yup.string().required()).required().meta({ @@ -83,12 +84,12 @@ function DeleteDialog(props: { ; } -function Actions({ row }: { row: Row }) { +function Actions({ row, invisible }: { row: Row, invisible: boolean }) { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); return ( - <> +
}) { onClick: () => setIsDeleteModalOpen(true), }]} /> - +
); } @@ -111,7 +112,14 @@ const columns: ColumnDef[] = [ { accessorKey: "id", header: ({ column }) => , - cell: ({ row }) => {row.getValue("id")}, + cell: ({ row }) => +
+ {row.original.id} + {row.original.id.startsWith('$') ? + + : null} +
+
, }, { accessorKey: "description", @@ -124,14 +132,14 @@ const columns: ColumnDef[] = [ column={column} columnTitle={
Contained Permissions - +
} />, cell: ({ row }) => , }, { id: "actions", - cell: ({ row }) => , + cell: ({ row }) => , }, ]; diff --git a/apps/dashboard/src/components/permission-field.tsx b/apps/dashboard/src/components/permission-field.tsx index 58a0e4831a..9f3bc68e37 100644 --- a/apps/dashboard/src/components/permission-field.tsx +++ b/apps/dashboard/src/components/permission-field.tsx @@ -121,6 +121,7 @@ export class PermissionGraph { export function PermissionListField(props: { control: Control, name: Path, + label: React.ReactNode, permissions: ServerPermissionDefinitionJson[], type: 'new' | 'edit' | 'edit-user', } & ({ @@ -173,7 +174,7 @@ export function PermissionListField(props: { name={props.name} render={({ field }) => ( - Contained permissions + {props.label}
{[...graph.permissions.values()].map(permission => { if (permission.id === CURRENTLY_EDITED_PERMISSION_SENTINEL) return null; diff --git a/apps/dashboard/src/lib/permissions.tsx b/apps/dashboard/src/lib/permissions.tsx index 456d459db8..04a7789dda 100644 --- a/apps/dashboard/src/lib/permissions.tsx +++ b/apps/dashboard/src/lib/permissions.tsx @@ -1,9 +1,22 @@ import { prismaClient } from "@/prisma-client"; -import { Prisma } from "@prisma/client"; +import { Prisma, TeamSystemPermission as DBTeamSystemPermission } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; -import { PermissionDefinitionJson, PermissionDefinitionScopeJson } from "@stackframe/stack-shared/dist/interface/clientInterface"; +import { PermissionDefinitionScopeJson } from "@stackframe/stack-shared/dist/interface/clientInterface"; import { ServerPermissionDefinitionCustomizableJson, ServerPermissionDefinitionJson } from "@stackframe/stack-shared/dist/interface/serverInterface"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { typedToLowercase, typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; +import * as yup from "yup"; + +export const teamPermissionIdSchema = yup.string() + .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; + if (value.startsWith('$') && !isTeamSystemPermission(value)) { + return ctx.createError({ message: 'Invalid system permission' }); + } + return true; + }); + export const fullPermissionInclude = { parentEdges: { @@ -13,6 +26,25 @@ export const fullPermissionInclude = { }, } as const satisfies Prisma.PermissionInclude; +export function isTeamSystemPermission(permission: string): permission is `$${Lowercase}` { + return permission.startsWith('$') && permission.slice(1).toUpperCase() in DBTeamSystemPermission; +} + +export function teamSystemPermissionStringToDBType(permission: `$${Lowercase}`): DBTeamSystemPermission { + return typedToUppercase(permission.slice(1)) as DBTeamSystemPermission; +} + +export function teamDBTypeToSystemPermissionString(permission: DBTeamSystemPermission): `$${Lowercase}` { + return '$' + typedToLowercase(permission) as `$${Lowercase}`; +} + +const teamSystemPermissionDescriptionMap: Record = { + "UPDATE_TEAM": "Update the team information", + "DELETE_TEAM": "Delete the team", + "READ_MEMBERS": "Read and list the other members of the team", + "REMOVE_MEMBERS": "Remove other members from the team", + "INVITE_MEMBERS": "Invite other users to the team", +}; function serverPermissionDefinitionJsonFromDbType( db: Prisma.PermissionGetPayload<{ include: typeof fullPermissionInclude }> @@ -30,11 +62,32 @@ function serverPermissionDefinitionJsonFromDbType( db.projectConfigId ? { type: "any-team" } : throwErr(new StackAssertionError(`Unexpected permission scope`, { db })), description: db.description || undefined, - containPermissionIds: db.parentEdges.map((edge) => edge.parentPermission.queryableId), + containPermissionIds: db.parentEdges.map((edge) => { + if (edge.parentPermission) { + return edge.parentPermission.queryableId; + } else if (edge.parentTeamSystemPermission) { + return '$' + typedToLowercase(edge.parentTeamSystemPermission); + } else { + throw new StackAssertionError(`Permission edge should have either parentPermission or parentSystemPermission`, { edge }); + } + }), + }; +} + +function serverPermissionDefinitionJsonFromTeamSystemDbType( + db: DBTeamSystemPermission, +): ServerPermissionDefinitionJson { + return { + __databaseUniqueId: '$' + typedToLowercase(db), + id: '$' + typedToLowercase(db), + scope: { type: "any-team" }, + description: teamSystemPermissionDescriptionMap[db], + containPermissionIds: [], }; } export async function listServerPermissionDefinitions(projectId: string, scope?: PermissionDefinitionScopeJson): Promise { + const results = []; switch (scope?.type) { case "specific-team": { const team = await prismaClient.team.findUnique({ @@ -51,7 +104,8 @@ export async function listServerPermissionDefinitions(projectId: string, scope?: }, }); if (!team) throw new KnownErrors.TeamNotFound(scope.teamId); - return team.permissions.map(serverPermissionDefinitionJsonFromDbType); + results.push(...team.permissions.map(serverPermissionDefinitionJsonFromDbType)); + break; } case "global": case "any-team": { @@ -68,7 +122,8 @@ export async function listServerPermissionDefinitions(projectId: string, scope?: }, include: fullPermissionInclude, }); - return res.map(serverPermissionDefinitionJsonFromDbType); + results.push(...res.map(serverPermissionDefinitionJsonFromDbType)); + break; } case undefined: { const res = await prismaClient.permission.findMany({ @@ -83,9 +138,17 @@ export async function listServerPermissionDefinitions(projectId: string, scope?: }, include: fullPermissionInclude, }); - return res.map(serverPermissionDefinitionJsonFromDbType); + results.push(...res.map(serverPermissionDefinitionJsonFromDbType)); + } + } + + if (scope === undefined || scope.type === "any-team" || scope.type === "specific-team") { + for (const systemPermission of Object.values(DBTeamSystemPermission)) { + results.push(serverPermissionDefinitionJsonFromTeamSystemDbType(systemPermission)); } } + + return results; } export async function grantTeamUserPermission({ @@ -144,6 +207,33 @@ export async function grantTeamUserPermission({ break; } case "team": { + if (isTeamSystemPermission(permissionId)) { + await prismaClient.teamMemberDirectPermission.upsert({ + where: { + projectId_projectUserId_teamId_systemPermission: { + projectId, + projectUserId, + teamId, + systemPermission: teamSystemPermissionStringToDBType(permissionId), + }, + }, + create: { + systemPermission: teamSystemPermissionStringToDBType(permissionId), + teamMember: { + connect: { + projectId_projectUserId_teamId: { + projectId: projectId, + projectUserId: projectUserId, + teamId: teamId, + }, + }, + }, + }, + update: {}, + }); + break; + } + const teamSpecificPermission = await prismaClient.permission.findUnique({ where: { projectId_teamId_queryableId: { @@ -237,6 +327,20 @@ export async function revokeTeamUserPermission({ break; } case "team": { + if (isTeamSystemPermission(permissionId)) { + await prismaClient.teamMemberDirectPermission.deleteMany({ + where: { + systemPermission: teamSystemPermissionStringToDBType(permissionId), + teamMember: { + projectId, + projectUserId, + teamId, + }, + }, + }); + break; + } + const teamSpecificPermission = await prismaClient.permission.findUnique({ where: { projectId_teamId_queryableId: { @@ -314,11 +418,15 @@ export async function listUserPermissionDefinitionsRecursive({ if (!user) throw new KnownErrors.UserNotFound(); const result = new Map(); - const idsToProcess = [...user.directPermissions.map(p => p.permission.queryableId)]; + const idsToProcess = [...user.directPermissions.map(p => + p.permission?.queryableId || + (p.systemPermission ? teamDBTypeToSystemPermissionString(p.systemPermission) : null) || + throwErr(new StackAssertionError(`Permission should have either queryableId or systemPermission`, { p })) + )]; while (idsToProcess.length > 0) { const currentId = idsToProcess.pop()!; const current = permissionsMap.get(currentId); - if (!current) throw new StackAssertionError(`Couldn't find permission in DB?`, { currentId, result, idsToProcess }); + if (!current) throw new StackAssertionError(`Couldn't find permission in DB`, { currentId, result, idsToProcess }); if (result.has(current.id)) continue; result.set(current.id, current); idsToProcess.push(...current.containPermissionIds); @@ -357,7 +465,15 @@ export async function listUserDirectPermissions({ }); if (!user) throw new KnownErrors.UserNotFound(); return user.directPermissions.map( - p => serverPermissionDefinitionJsonFromDbType(p.permission) + p => { + if (p.permission) { + return serverPermissionDefinitionJsonFromDbType(p.permission); + } else if (p.systemPermission) { + return serverPermissionDefinitionJsonFromTeamSystemDbType(p.systemPermission); + } else { + throw new StackAssertionError(`Permission should have either permission or systemPermission`, { p }); + } + } ).filter( p => { switch (p.scope.type) { @@ -374,16 +490,20 @@ export async function listUserDirectPermissions({ } export async function listPotentialParentPermissions(projectId: string, scope: PermissionDefinitionScopeJson): Promise { - const scopes: PermissionDefinitionScopeJson[] = [ - { type: "global" } as const, - ...scope.type === "global" ? [] : [ - { type: "any-team" } as const, + if (scope.type === "global") { + return await listServerPermissionDefinitions(projectId, { type: "global" }); + } else { + const scopes: PermissionDefinitionScopeJson[] = [ + { type: "any-team" }, ...scope.type === "any-team" ? [] : [ { type: "specific-team", teamId: scope.teamId } as const, ], - ], - ]; - return (await Promise.all(scopes.map(s => listServerPermissionDefinitions(projectId, s)))).flat(1); + ]; + + const permissions = (await Promise.all(scopes.map(s => listServerPermissionDefinitions(projectId, s))).then(res => res.flat(1))); + const systemPermissions = Object.values(DBTeamSystemPermission).map(serverPermissionDefinitionJsonFromTeamSystemDbType); + return [...permissions, ...systemPermissions]; + } } export async function createPermissionDefinition( @@ -417,13 +537,21 @@ export async function createPermissionDefinition( projectConfigId: project.configId, }, parentEdges: { - create: parentDbIds.map(parentDbId => ({ - parentPermission: { - connect: { - dbId: parentDbId, - }, - }, - })), + create: parentDbIds.map(parentDbId => { + if (isTeamSystemPermission(parentDbId)) { + return { + parentTeamSystemPermission: teamSystemPermissionStringToDBType(parentDbId), + }; + } else { + return { + parentPermission: { + connect: { + dbId: parentDbId, + }, + }, + }; + } + }) }, }, include: fullPermissionInclude, @@ -459,13 +587,21 @@ export async function updatePermissionDefinitions( edgeUpdateData = { parentEdges: { deleteMany: {}, - create: parentDbIds.map(parentDbId => ({ - parentPermission: { - connect: { - dbId: parentDbId, - }, - }, - })), + create: parentDbIds.map(parentDbId => { + if (isTeamSystemPermission(parentDbId)) { + return { + parentTeamSystemPermission: teamSystemPermissionStringToDBType(parentDbId), + }; + } else { + return { + parentPermission: { + connect: { + dbId: parentDbId, + }, + }, + }; + } + }), }, }; } diff --git a/apps/dashboard/src/lib/projects.tsx b/apps/dashboard/src/lib/projects.tsx index f2325d676e..9754f1c58d 100644 --- a/apps/dashboard/src/lib/projects.tsx +++ b/apps/dashboard/src/lib/projects.tsx @@ -8,7 +8,7 @@ import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { EmailConfigJson, SharedProvider, StandardProvider, sharedProviders, standardProviders } from "@stackframe/stack-shared/dist/interface/clientInterface"; import { OAuthProviderUpdateOptions, ProjectUpdateOptions } from "@stackframe/stack-shared/dist/interface/adminInterface"; import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings"; +import { isTeamSystemPermission, listServerPermissionDefinitions, teamDBTypeToSystemPermissionString, teamPermissionIdSchema, teamSystemPermissionStringToDBType } from "./permissions"; function toDBSharedProvider(type: SharedProvider): ProxiedOAuthProviderType { @@ -67,6 +67,7 @@ export const fullProjectInclude = { standardEmailServiceConfig: true, }, }, + permissions: true, domains: true, }, }, @@ -89,6 +90,9 @@ export type ProjectDB = Prisma.ProjectGetPayload<{ include: FullProjectInclude } domains: Prisma.ProjectDomainGetPayload< typeof fullProjectInclude.config.include.domains >[], + permissions: Prisma.PermissionGetPayload< + typeof fullProjectInclude.config.include.permissions + >[], }, }; @@ -191,6 +195,38 @@ export async function createProject( include: fullProjectInclude, }); + await tx.permission.create({ + data: { + projectId: project.id, + projectConfigId: project.config.id, + queryableId: "member", + description: "Default permission for team members", + scope: 'TEAM', + parentEdges: { + createMany: { + data: (['READ_MEMBERS', 'INVITE_MEMBERS'] as const).map(p => ({ parentTeamSystemPermission: p })), + }, + }, + isDefaultTeamMemberPermission: true, + }, + }); + + await tx.permission.create({ + data: { + projectId: project.id, + projectConfigId: project.config.id, + queryableId: "admin", + description: "Default permission for team creators", + scope: 'TEAM', + parentEdges: { + createMany: { + data: (['UPDATE_TEAM', 'DELETE_TEAM', 'READ_MEMBERS', 'REMOVE_MEMBERS', 'INVITE_MEMBERS'] as const).map(p =>({ parentTeamSystemPermission: p })) + }, + }, + isDefaultTeamCreatorPermission: true, + }, + }); + const projectUserTx = await tx.projectUser.findUniqueOrThrow({ where: { projectId_projectUserId: { @@ -429,6 +465,68 @@ async function _createEmailConfigUpdateTransactions( return transactions; } +async function _createDefaultPermissionsUpdateTransactions( + projectId: string, + options: ProjectUpdateOptions +) { + const project = await prismaClient.project.findUnique({ + where: { id: projectId }, + include: fullProjectInclude, + }); + + if (!project) { + throw new Error(`Project with id '${projectId}' not found`); + } + + const transactions = []; + const permissions = await listServerPermissionDefinitions(projectId, { type: 'any-team' }); + + const params = [ + { + optionName: 'teamCreatorDefaultPermissionIds', + dbName: 'teamCreatorDefaultPermissions', + dbSystemName: 'teamCreateDefaultSystemPermissions', + }, + { + optionName: 'teamMemberDefaultPermissionIds', + dbName: 'teamMemberDefaultPermissions', + dbSystemName: 'teamMemberDefaultSystemPermissions', + }, + ] as const; + + for (const param of params) { + const creatorPerms = options.config?.[param.optionName]; + if (creatorPerms) { + if (!creatorPerms.every((id) => permissions.some((perm) => perm.id === id))) { + throw new StatusError(StatusError.BadRequest, "Invalid team default permission ids"); + } + + const connect = creatorPerms + .filter(x => !isTeamSystemPermission(x)) + .map((id) => ({ + projectConfigId_queryableId: { + projectConfigId: project.config.id, + queryableId: id + }, + })); + + const systemPerms = creatorPerms + .filter(isTeamSystemPermission) + .map(teamSystemPermissionStringToDBType); + + transactions.push(prismaClient.projectConfig.update({ + where: { id: project.config.id }, + data: { + [param.dbName]: { connect }, + [param.dbSystemName]: systemPerms, + }, + })); + } + } + + return transactions; +} + export async function updateProject( projectId: string, options: ProjectUpdateOptions, @@ -467,6 +565,7 @@ export async function updateProject( transaction.push(...(await _createOAuthConfigUpdateTransactions(projectId, options))); transaction.push(...(await _createEmailConfigUpdateTransactions(projectId, options))); + transaction.push(...(await _createDefaultPermissionsUpdateTransactions(projectId, options))); transaction.push(prismaClient.projectConfig.update({ where: { id: project.config.id }, @@ -561,6 +660,12 @@ export function projectJsonFromDbType(project: ProjectDB): ProjectJson { return []; }), emailConfig, + teamCreatorDefaultPermissionIds: project.config.permissions.filter(perm => perm.isDefaultTeamCreatorPermission) + .map((perm) => perm.queryableId) + .concat(project.config.teamCreateDefaultSystemPermissions.map(teamDBTypeToSystemPermissionString)), + teamMemberDefaultPermissionIds: project.config.permissions.filter(perm => perm.isDefaultTeamMemberPermission) + .map((perm) => perm.queryableId) + .concat(project.config.teamMemberDefaultSystemPermissions.map(teamDBTypeToSystemPermissionString)), }, }; } @@ -607,6 +712,8 @@ const nonRequiredSchemas = { password: requiredWhenShared(yup.string()), senderEmail: requiredWhenShared(yup.string().email()), }).optional().default(undefined), + teamCreatorDefaultPermissionIds: yup.array(teamPermissionIdSchema.required()).optional().default(undefined), + teamMemberDefaultPermissionIds: yup.array(teamPermissionIdSchema.required()).optional().default(undefined), }).optional().default(undefined), }; @@ -672,6 +779,8 @@ export const projectSchemaToUpdateOptions = ( senderEmail: update.config.emailConfig.senderEmail!, } ), + teamCreatorDefaultPermissionIds: update.config.teamCreatorDefaultPermissionIds, + teamMemberDefaultPermissionIds: update.config.teamMemberDefaultPermissionIds, }, }; }; diff --git a/apps/dashboard/src/lib/teams.tsx b/apps/dashboard/src/lib/teams.tsx index 2d9ad4ba25..520ea05f93 100644 --- a/apps/dashboard/src/lib/teams.tsx +++ b/apps/dashboard/src/lib/teams.tsx @@ -1,10 +1,13 @@ import { prismaClient } from "@/prisma-client"; -import { TeamJson } from "@stackframe/stack-shared/dist/interface/clientInterface"; +import { TeamCustomizableJson, TeamJson } from "@stackframe/stack-shared/dist/interface/clientInterface"; import { ServerTeamCustomizableJson, ServerTeamJson, ServerTeamMemberJson } from "@stackframe/stack-shared/dist/interface/serverInterface"; import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; import { Prisma } from "@prisma/client"; import { getServerUserFromDbType } from "./users"; import { serverUserInclude } from "./users"; +import { getProject } from "./projects"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { grantTeamUserPermission } from "./permissions"; // TODO technically we can split this; listUserTeams only needs `team`, and listServerTeams only needs `projectUser`; listTeams needs neither // note: this is a function to prevent circular dependencies between the teams and users file @@ -91,7 +94,7 @@ export async function updateServerTeam(projectId: string, teamId: string, update }); } -export async function createServerTeam(projectId: string, team: ServerTeamCustomizableJson): Promise { +export async function createTeam(projectId: string, team: TeamCustomizableJson): Promise { const result = await prismaClient.team.create({ data: { projectId, @@ -105,6 +108,10 @@ export async function createServerTeam(projectId: string, team: ServerTeamCustom }; } +export async function createServerTeam(projectId: string, team: ServerTeamCustomizableJson): Promise { + return await createTeam(projectId, team); // currently ServerTeam and ClientTeam are the same +} + export async function deleteServerTeam(projectId: string, teamId: string): Promise { const deleted = await prismaClient.team.delete({ where: { @@ -116,22 +123,22 @@ export async function deleteServerTeam(projectId: string, teamId: string): Promi }); } -export async function addUserToTeam(projectId: string, teamId: string, userId: string): Promise { +export async function addUserToTeam(options: { projectId: string, teamId: string, userId: string }): Promise { await prismaClient.teamMember.create({ data: { - projectId, - teamId, - projectUserId: userId, + projectId: options.projectId, + teamId: options.teamId, + projectUserId: options.userId, }, }); } -export async function removeUserFromTeam(projectId: string, teamId: string, userId: string): Promise { +export async function removeUserFromTeam(options: { projectId: string, teamId: string, userId: string }): Promise { await prismaClient.teamMember.deleteMany({ where: { - projectId, - teamId, - projectUserId: userId, + projectId: options.projectId, + teamId: options.teamId, + projectUserId: options.userId, }, }); } @@ -160,3 +167,44 @@ export function getServerTeamMemberFromDbType(member: ServerTeamMemberDB): Serve displayName: member.projectUser.displayName, }; } + +async function grantDefaultTeamPermissions(options: { projectId: string, teamId: string, userId: string, type: 'creator' | 'member' }): Promise { + const project = await getProject(options.projectId); + if (!project) { + throw new StackAssertionError("Project not found"); + } + + const permissionIds = options.type === 'creator' ? + project.evaluatedConfig.teamCreatorDefaultPermissionIds : + project.evaluatedConfig.teamMemberDefaultPermissionIds; + + // TODO: improve performance by batching + for (const permissionId of permissionIds) { + await grantTeamUserPermission({ + projectId: options.projectId, + teamId: options.teamId, + projectUserId: options.userId, + permissionId, + type: 'team', + }); + } +} + +export async function grantDefaultTeamCreatorPermissions(options: { projectId: string, teamId: string, userId: string }): Promise { + await grantDefaultTeamPermissions({ ...options, type: 'creator' }); +} + +export async function grantDefaultTeamMemberPermissions(options: { projectId: string, teamId: string, userId: string }): Promise { + await grantDefaultTeamPermissions({ ...options, type: 'member' }); +} + +export async function createTeamForUser(options: { projectId: string, userId: string, data: ServerTeamCustomizableJson }): Promise { + const team = await createTeam(options.projectId, options.data); + await addUserToTeam({ projectId: options.projectId, teamId: team.id, userId: options.userId }); + await grantDefaultTeamCreatorPermissions({ projectId: options.projectId, teamId: team.id, userId: options.userId }); + return team; +} + +export async function createServerTeamForUser(options: { projectId: string, userId: string, data: ServerTeamCustomizableJson }): Promise { + return await createTeamForUser(options); // currently ServerTeam and ClientTeam are the same +} \ No newline at end of file diff --git a/apps/dashboard/src/lib/users.tsx b/apps/dashboard/src/lib/users.tsx index 632af9d3eb..b39d8ddac6 100644 --- a/apps/dashboard/src/lib/users.tsx +++ b/apps/dashboard/src/lib/users.tsx @@ -5,7 +5,11 @@ import { getProject } from "@/lib/projects"; import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; import { UserUpdateJson } from "@stackframe/stack-shared/dist/interface/clientInterface"; import { ServerUserUpdateJson } from "@stackframe/stack-shared/dist/interface/serverInterface"; -import { addUserToTeam, createServerTeam, getClientTeamFromServerTeam, getServerTeamFromDbType } from "./teams"; +import { + createServerTeamForUser, + getClientTeamFromServerTeam, + getServerTeamFromDbType, +} from "./teams"; export const serverUserInclude = { projectUserOAuthAccounts: true, @@ -163,9 +167,9 @@ export async function createTeamOnSignUp(projectId: string, userId: string): Pro throw new Error('User not found'); } - const team = await createServerTeam( - projectId, - { displayName: user.displayName ? `${user.displayName}'s personal team` : 'Personal team' } - ); - await addUserToTeam(projectId, team.id, userId); + await createServerTeamForUser({ + projectId, + userId, + data: { displayName: user.displayName ? `${user.displayName}'s personal team` : 'Personal team' }, + }); } diff --git a/examples/demo/src/app/teams/create-team.tsx b/examples/demo/src/app/teams/create-team.tsx index 71078c42e8..7338fee6ad 100644 --- a/examples/demo/src/app/teams/create-team.tsx +++ b/examples/demo/src/app/teams/create-team.tsx @@ -1,18 +1,18 @@ "use client"; -import { Input, Button } from "@stackframe/stack"; +import { Input, Button, useUser } from "@stackframe/stack"; import React from "react"; -import { createTeam } from "./server-actions"; export function CreateTeam() { const [displayName, setDisplayName] = React.useState(''); + const user = useUser({ or: 'redirect' }); return (
setDisplayName(e.target.value)} placeholder='Team Name' />