Fix error where deleting a team creator default permission would make the dashboard crash

This commit is contained in:
Konstantin Wohlwend 2025-08-11 17:42:35 -07:00
parent 09d1156142
commit 037068e432
8 changed files with 152 additions and 32 deletions

View File

@ -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<Promise<OrganizationRenderedConfig>> {
export function getRenderedOrganizationConfigQuery(options: OrganizationOptions): RawQuery<Promise<CompleteConfig>> {
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)),

View File

@ -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);

View File

@ -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 };

View File

@ -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);

View File

@ -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 { <some fields may have been hidden> },
}
`);
// Step 2: Update project to set team creator default permissions to include the new permission
const updateProjectResponse = await niceBackendFetch(`/api/v1/internal/projects/current`, {
accessType: "admin",
method: "PATCH",
body: {
config: {
team_creator_default_permissions: [
{ id: "team_admin" },
{ id: "custom_team_permission" }
]
}
},
headers: {
'x-stack-admin-access-token': adminAccessToken
},
});
expect(updateProjectResponse.status).toBe(200);
expect(updateProjectResponse.body.config.team_creator_default_permissions).toMatchInlineSnapshot(`
[
{ "id": "custom_team_permission" },
{ "id": "team_admin" },
]
`);
// Step 3: Verify the permission is in the team creator default permissions
const getProjectResponse1 = await niceBackendFetch(`/api/v1/internal/projects/current`, {
accessType: "admin",
method: "GET",
headers: {
'x-stack-admin-access-token': adminAccessToken
},
});
expect(getProjectResponse1.status).toBe(200);
expect(getProjectResponse1.body.config.team_creator_default_permissions).toMatchInlineSnapshot(`
[
{ "id": "custom_team_permission" },
{ "id": "team_admin" },
]
`);
// Step 4: Delete the permission definition
const deletePermissionResponse = await niceBackendFetch(`/api/v1/team-permission-definitions/custom_team_permission`, {
accessType: "admin",
method: "DELETE",
headers: {
'x-stack-admin-access-token': adminAccessToken
},
});
expect(deletePermissionResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
// Step 5: Verify the deleted permission is no longer in team creator default permissions
const getProjectResponse2 = await niceBackendFetch(`/api/v1/internal/projects/current`, {
accessType: "admin",
method: "GET",
headers: {
'x-stack-admin-access-token': adminAccessToken
},
});
expect(getProjectResponse2.status).toBe(200);
expect(getProjectResponse2.body.config.team_creator_default_permissions).toMatchInlineSnapshot(`
[{ "id": "team_admin" }]
`);
});

View File

@ -926,6 +926,8 @@ export type BranchRenderedConfig = Expand<Awaited<ReturnType<typeof sanitizeBran
export type EnvironmentRenderedConfig = Expand<Awaited<ReturnType<typeof sanitizeEnvironmentConfig<EnvironmentRenderedConfigBeforeSanitization>>>>;
export type OrganizationRenderedConfig = Expand<Awaited<ReturnType<typeof sanitizeOrganizationConfig>>>;
// Complete config
export type CompleteConfig = OrganizationRenderedConfig;
// Type assertions (just to make sure the types are correct)
const __assertEmptyObjectIsValidProjectOverride: ProjectConfigOverride = {};

View File

@ -18,7 +18,7 @@ import { StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/ad
import { clientVersion, createCache, getBaseUrl, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey } from "./common";
import { _StackServerAppImplIncomplete } from "./server-app-impl";
import { EnvironmentConfigOverrideOverride, OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema";
import { CompleteConfig, EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema";
import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface";
import { ConfigCrud } from "@stackframe/stack-shared/dist/interface/crud/config";
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
@ -87,7 +87,7 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
});
}
_adminConfigFromCrud(data: ConfigCrud['Admin']['Read']): OrganizationRenderedConfig {
_adminConfigFromCrud(data: ConfigCrud['Admin']['Read']): CompleteConfig {
return JSON.parse(data.config_string);
}

View File

@ -1,7 +1,7 @@
import { ProductionModeError } from "@stackframe/stack-shared/dist/helpers/production-mode";
import { AdminUserProjectsCrud, ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { EnvironmentConfigOverrideOverride, OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema";
import { CompleteConfig, EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema";
import { StackAdminApp } from "../apps/interfaces/admin-app";
import { AdminProjectConfig, AdminProjectConfigUpdateOptions, ProjectConfig } from "../project-configs";
@ -23,9 +23,9 @@ export type AdminProject = {
update(this: AdminProject, update: AdminProjectUpdateOptions): Promise<void>,
delete(this: AdminProject): Promise<void>,
getConfig(this: AdminProject): Promise<OrganizationRenderedConfig>,
getConfig(this: AdminProject): Promise<CompleteConfig>,
// NEXT_LINE_PLATFORM react-like
useConfig(this: AdminProject): OrganizationRenderedConfig,
useConfig(this: AdminProject): CompleteConfig,
updateConfig(this: AdminProject, config: EnvironmentConfigOverrideOverride): Promise<void>,
getProductionModeErrors(this: AdminProject): Promise<ProductionModeError[]>,