diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 3817cd98c..61c47b4c6 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -1,6 +1,6 @@ import { Prisma } from "@prisma/client"; import { Config, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format"; -import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, OrganizationRenderedConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, assertNoConfigOverrideErrors, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, migrateConfigOverride, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, CompleteConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, assertNoConfigOverrideErrors, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, migrateConfigOverride, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; import { isTruthy } from "@stackframe/stack-shared/dist/utils/booleans"; @@ -9,7 +9,8 @@ import { filterUndefined, typedEntries } from "@stackframe/stack-shared/dist/uti import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { deindent, stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import * as yup from "yup"; -import { PrismaClientTransaction, RawQuery, globalPrismaClient, rawQuery } from "../prisma-client"; +import { RawQuery, globalPrismaClient, rawQuery } from "../prisma-client"; +import { listPermissionDefinitionsFromConfig } from "./permissions"; import { DEFAULT_BRANCH_ID } from "./tenancies"; type ProjectOptions = { projectId: string }; @@ -46,7 +47,7 @@ export function getRenderedEnvironmentConfigQuery(options: EnvironmentOptions): ); } -export function getRenderedOrganizationConfigQuery(options: OrganizationOptions): RawQuery> { +export function getRenderedOrganizationConfigQuery(options: OrganizationOptions): RawQuery> { return RawQuery.then( getIncompleteOrganizationConfigQuery(options), async (incompleteConfig) => await sanitizeOrganizationConfig(normalize(applyOrganizationDefaults(await incompleteConfig), { onDotIntoNonObject: "ignore" }) as any), @@ -469,7 +470,7 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe // --------------------------------------------------------------------------------------------------------------------- // C -> A -export const renderedOrganizationConfigToProjectCrud = (renderedConfig: OrganizationRenderedConfig): ProjectsCrud["Admin"]["Read"]['config'] => { +export const renderedOrganizationConfigToProjectCrud = (renderedConfig: CompleteConfig): ProjectsCrud["Admin"]["Read"]['config'] => { const oauthProviders = typedEntries(renderedConfig.auth.oauth.providers) .map(([oauthProviderId, oauthProvider]) => { if (!oauthProvider.type) { @@ -491,6 +492,15 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Organiza .filter(isTruthy) .sort((a, b) => stringCompare(a.id, b.id)); + const teamPermissionDefinitions = listPermissionDefinitionsFromConfig({ + config: renderedConfig, + scope: "team", + }); + const projectPermissionDefinitions = listPermissionDefinitionsFromConfig({ + config: renderedConfig, + scope: "project", + }); + return { allow_localhost: renderedConfig.domains.allowLocalhost, client_team_creation_enabled: renderedConfig.teams.allowClientTeamCreation, @@ -527,15 +537,15 @@ export const renderedOrganizationConfigToProjectCrud = (renderedConfig: Organiza email_theme: renderedConfig.emails.selectedThemeId, team_creator_default_permissions: typedEntries(renderedConfig.rbac.defaultPermissions.teamCreator) - .filter(([_, perm]) => perm) + .filter(([id, perm]) => perm && teamPermissionDefinitions.some((p) => p.id === id)) .map(([id, perm]) => ({ id })) .sort((a, b) => stringCompare(a.id, b.id)), team_member_default_permissions: typedEntries(renderedConfig.rbac.defaultPermissions.teamMember) - .filter(([_, perm]) => perm) + .filter(([id, perm]) => perm && teamPermissionDefinitions.some((p) => p.id === id)) .map(([id, perm]) => ({ id })) .sort((a, b) => stringCompare(a.id, b.id)), user_default_permissions: typedEntries(renderedConfig.rbac.defaultPermissions.signUp) - .filter(([_, perm]) => perm) + .filter(([id, perm]) => perm && projectPermissionDefinitions.some((p) => p.id === id)) .map(([id, perm]) => ({ id })) .sort((a, b) => stringCompare(a.id, b.id)), diff --git a/apps/backend/src/lib/permissions.tsx b/apps/backend/src/lib/permissions.tsx index c675b9784..785ff71c5 100644 --- a/apps/backend/src/lib/permissions.tsx +++ b/apps/backend/src/lib/permissions.tsx @@ -1,5 +1,5 @@ import { KnownErrors } from "@stackframe/stack-shared"; -import { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import { ProjectPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/project-permissions"; import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions"; import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays"; @@ -158,15 +158,13 @@ export async function revokeTeamPermission( }); } -export async function listPermissionDefinitions( +export function listPermissionDefinitionsFromConfig( options: { + config: CompleteConfig, scope: "team" | "project", - tenancy: Tenancy, - } -): Promise<(TeamPermissionDefinitionsCrud["Admin"]["Read"])[]> { - const renderedConfig = options.tenancy.config; - - const permissions = typedEntries(renderedConfig.rbac.permissions).filter(([_, p]) => p.scope === options.scope); + }, +) { + const permissions = typedEntries(options.config.rbac.permissions).filter(([_, p]) => p.scope === options.scope); return [ ...permissions.map(([id, p]) => ({ @@ -182,6 +180,18 @@ export async function listPermissionDefinitions( ].sort((a, b) => stringCompare(a.id, b.id)); } +export async function listPermissionDefinitions( + options: { + scope: "team" | "project", + tenancy: Tenancy, + } +): Promise<(TeamPermissionDefinitionsCrud["Admin"]["Read"])[]> { + return listPermissionDefinitionsFromConfig({ + config: options.tenancy.config, + scope: options.scope, + }); +} + export async function createPermissionDefinition( globalTx: PrismaTransaction, options: { @@ -196,7 +206,7 @@ export async function createPermissionDefinition( ) { const oldConfig = options.tenancy.config; - const existingPermission = oldConfig.rbac.permissions[options.data.id] as OrganizationRenderedConfig['rbac']['permissions'][string] | undefined; + const existingPermission = oldConfig.rbac.permissions[options.data.id] as CompleteConfig['rbac']['permissions'][string] | undefined; const allIds = Object.keys(oldConfig.rbac.permissions) .filter(id => oldConfig.rbac.permissions[id].scope === options.scope) .concat(Object.keys(options.scope === "team" ? teamSystemPermissionMap : {})); @@ -249,7 +259,7 @@ export async function updatePermissionDefinition( const newId = options.data.id ?? options.oldId; const oldConfig = options.tenancy.config; - const existingPermission = oldConfig.rbac.permissions[options.oldId] as OrganizationRenderedConfig['rbac']['permissions'][string] | undefined; + const existingPermission = oldConfig.rbac.permissions[options.oldId] as CompleteConfig['rbac']['permissions'][string] | undefined; if (!existingPermission) { throw new KnownErrors.PermissionNotFound(options.oldId); @@ -335,7 +345,7 @@ export async function deletePermissionDefinition( ) { const oldConfig = options.tenancy.config; - const existingPermission = oldConfig.rbac.permissions[options.permissionId] as OrganizationRenderedConfig['rbac']['permissions'][string] | undefined; + const existingPermission = oldConfig.rbac.permissions[options.permissionId] as CompleteConfig['rbac']['permissions'][string] | undefined; if (!existingPermission || existingPermission.scope !== options.scope) { throw new KnownErrors.PermissionNotFound(options.permissionId); diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index dc62a4d7f..7b5c00702 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -1,6 +1,6 @@ import { Prisma } from "@prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; -import { EnvironmentConfigOverrideOverride, OrganizationRenderedConfig, ProjectConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; +import { CompleteConfig, EnvironmentConfigOverrideOverride, ProjectConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; import { AdminUserProjectsCrud, ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; @@ -158,7 +158,7 @@ export async function createOrUpdateProjectWithLegacyConfig( microsoftTenantId: provider.microsoft_tenant_id, allowSignIn: true, allowConnectedAccounts: true, - } satisfies OrganizationRenderedConfig['auth']['oauth']['providers'][string] + } satisfies CompleteConfig['auth']['oauth']['providers'][string] ]; })) : undefined, // ======================= users ======================= @@ -174,7 +174,7 @@ export async function createOrUpdateProjectWithLegacyConfig( { baseUrl: domain.domain, handlerPath: domain.handler_path, - } satisfies OrganizationRenderedConfig['domains']['trustedDomains'][string], + } satisfies CompleteConfig['domains']['trustedDomains'][string], ]; })) : undefined, // ======================= api keys ======================= @@ -189,7 +189,7 @@ export async function createOrUpdateProjectWithLegacyConfig( password: dataOptions.email_config.password, senderName: dataOptions.email_config.sender_name, senderEmail: dataOptions.email_config.sender_email, - } satisfies OrganizationRenderedConfig['emails']['server'] : undefined, + } satisfies CompleteConfig['emails']['server'] : undefined, 'emails.selectedThemeId': dataOptions.email_theme, // ======================= rbac ======================= 'rbac.defaultPermissions.teamMember': translateDefaultPermissions(dataOptions.team_member_default_permissions), @@ -205,7 +205,7 @@ export async function createOrUpdateProjectWithLegacyConfig( '$read_members': true, '$invite_members': true, }, - } satisfies OrganizationRenderedConfig['rbac']['permissions'][string]; + } satisfies CompleteConfig['rbac']['permissions'][string]; configOverrideOverride['rbac.permissions.team_admin'] ??= { description: "Default permission for team admins", scope: "team", @@ -217,7 +217,7 @@ export async function createOrUpdateProjectWithLegacyConfig( '$invite_members': true, '$manage_api_keys': true, }, - } satisfies OrganizationRenderedConfig['rbac']['permissions'][string]; + } satisfies CompleteConfig['rbac']['permissions'][string]; configOverrideOverride['rbac.defaultPermissions.teamCreator'] ??= { 'team_admin': true }; configOverrideOverride['rbac.defaultPermissions.teamMember'] ??= { 'team_member': true }; diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 261f689f6..e651de3ee 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -1,7 +1,7 @@ import { PrismaNeon } from "@prisma/adapter-neon"; import { PrismaPg } from '@prisma/adapter-pg'; import { Prisma, PrismaClient } from "@prisma/client"; -import { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env'; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { globalVar } from "@stackframe/stack-shared/dist/utils/globals"; @@ -70,7 +70,7 @@ function getPostgresPrismaClient(connectionString: string) { return postgresPrismaClient; } -export async function getPrismaClientForSourceOfTruth(sourceOfTruth: OrganizationRenderedConfig["sourceOfTruth"], branchId: string) { +export async function getPrismaClientForSourceOfTruth(sourceOfTruth: CompleteConfig["sourceOfTruth"], branchId: string) { switch (sourceOfTruth.type) { case 'neon': { if (!(branchId in sourceOfTruth.connectionStrings)) { @@ -92,7 +92,7 @@ export async function getPrismaClientForSourceOfTruth(sourceOfTruth: Organizatio } } -export function getPrismaSchemaForSourceOfTruth(sourceOfTruth: OrganizationRenderedConfig["sourceOfTruth"], branchId: string) { +export function getPrismaSchemaForSourceOfTruth(sourceOfTruth: CompleteConfig["sourceOfTruth"], branchId: string) { switch (sourceOfTruth.type) { case 'postgres': { return getSchemaFromConnectionString(sourceOfTruth.connectionString); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/team-permission-definitions.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/team-permission-definitions.test.ts index 90367ad86..e3ab47843 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/team-permission-definitions.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/team-permission-definitions.test.ts @@ -518,3 +518,101 @@ it("cannot update a team permission definition to contain a permission that does } `); }); + +it("removes deleted permission definition from team creator default permissions", async ({ expect }) => { + backendContext.set({ projectKeys: InternalProjectKeys }); + const { adminAccessToken } = await Project.createAndGetAdminToken(); + + // Step 1: Create a new team permission definition + const createPermissionResponse = await niceBackendFetch(`/api/v1/team-permission-definitions`, { + accessType: "admin", + method: "POST", + body: { + id: 'custom_team_permission', + description: "Custom team permission for testing" + }, + headers: { + 'x-stack-admin-access-token': adminAccessToken + }, + }); + expect(createPermissionResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 201, + "body": { + "contained_permission_ids": [], + "description": "Custom team permission for testing", + "id": "custom_team_permission", + }, + "headers": Headers {