Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions apps/backend/src/app/api/latest/users/crud.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { getRenderedEnvironmentConfigQuery } from "@/lib/config";
import { getRenderedProjectConfigQuery } from "@/lib/config";
import { normalizeEmail } from "@/lib/emails";
import { grantDefaultProjectPermissions } from "@/lib/permissions";
import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks";
import { Tenancy, getSoleTenancyFromProjectBranch, getTenancy } from "@/lib/tenancies";
import { Tenancy, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { PrismaTransaction } from "@/lib/types";
import { sendTeamMembershipDeletedWebhook, sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks";
import { RawQuery, getPrismaClientForSourceOfTruth, getPrismaClientForTenancy, getPrismaSchemaForSourceOfTruth, getPrismaSchemaForTenancy, globalPrismaClient, rawQuery, retryTransaction, sqlQuoteIdent } from "@/prisma-client";
Expand Down Expand Up @@ -388,20 +388,21 @@ export function getUserIfOnGlobalPrismaClientQuery(projectId: string, branchId:
};
}

export async function getUser(options: { userId: string } & ({ projectId: string, branchId: string } | { tenancyId: string })) {
let projectId, branchId;
if (!("tenancyId" in options)) {
export async function getUser(options: { userId: string } & ({ projectId: string, branchId: string } | { tenancy: Tenancy })) {
let projectId, branchId, sourceOfTruth;
if ("tenancy" in options) {
projectId = options.tenancy.project.id;
branchId = options.tenancy.branchId;
sourceOfTruth = options.tenancy.config.sourceOfTruth;
} else {
projectId = options.projectId;
branchId = options.branchId;
} else {
const tenancy = await getTenancy(options.tenancyId) ?? throwErr("Tenancy not found", { tenancyId: options.tenancyId });
projectId = tenancy.project.id;
branchId = tenancy.branchId;
const projectConfig = await rawQuery(globalPrismaClient, getRenderedProjectConfigQuery({ projectId }));
sourceOfTruth = projectConfig.sourceOfTruth;
}

const environmentConfig = await rawQuery(globalPrismaClient, getRenderedEnvironmentConfigQuery({ projectId, branchId }));
const prisma = await getPrismaClientForSourceOfTruth(environmentConfig.sourceOfTruth, branchId);
const schema = await getPrismaSchemaForSourceOfTruth(environmentConfig.sourceOfTruth, branchId);
const prisma = await getPrismaClientForSourceOfTruth(sourceOfTruth, branchId);
const schema = await getPrismaSchemaForSourceOfTruth(sourceOfTruth, branchId);
const result = await rawQuery(prisma, getUserQuery(projectId, branchId, options.userId, schema));
return result;
}
Expand All @@ -421,7 +422,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
include_anonymous: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to include anonymous users in the results. Defaults to false" } }),
}),
onRead: async ({ auth, params, query }) => {
const user = await getUser({ tenancyId: auth.tenancy.id, userId: params.user_id });
const user = await getUser({ tenancy: auth.tenancy, userId: params.user_id });
if (!user) {
throw new KnownErrors.UserNotFound();
}
Expand Down
11 changes: 4 additions & 7 deletions apps/backend/src/lib/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,24 +131,21 @@ export function getProjectConfigOverrideQuery(options: ProjectOptions): RawQuery
if (queryResult.length > 1) {
throw new StackAssertionError(`Expected 0 or 1 project config overrides for project ${options.projectId}, got ${queryResult.length}`, { queryResult });
}
if (queryResult.length === 0) {
throw new StackAssertionError(`Expected a project row for project ${options.projectId}, got 0`, { queryResult, options });
}
return migrateConfigOverride("project", queryResult[0].projectConfigOverride ?? {});
return migrateConfigOverride("project", queryResult[0]?.projectConfigOverride ?? {});
},
};
}

export function getBranchConfigOverrideQuery(options: BranchOptions): RawQuery<Promise<BranchConfigOverride>> {
// fetch branch config from GitHub
// (currently it's just empty)
if (options.branchId !== DEFAULT_BRANCH_ID) {
throw new StackAssertionError('Not implemented');
}
return {
supportedPrismaClients: ["global"],
sql: Prisma.sql`SELECT 1`,
postProcess: async () => {
if (options.branchId !== DEFAULT_BRANCH_ID) {
throw new StackAssertionError('getBranchConfigOverrideQuery is not implemented for branches other than the default one');
}
Comment on lines +146 to +148
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Invalid branch still returns 500

This guard still throws a StackAssertionError whenever branchId !== DEFAULT_BRANCH_ID, so the bundled environmentRenderedConfig query in parseAuth explodes before the tenancy lookup can emit KnownErrors.BranchDoesNotExist. Repro: send any request with x-stack-project-id set to a real project and x-stack-branch-id: invalid-branch; you still get the old “Not implemented” 500 from here, so the new e2e test will keep failing. Please drop the throw (return the empty override instead) so the tenancy logic can surface the intended 400.

-      if (options.branchId !== DEFAULT_BRANCH_ID) {
-        throw new StackAssertionError('getBranchConfigOverrideQuery is not implemented for branches other than the default one');
-      }
       return migrateConfigOverride("branch", {});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (options.branchId !== DEFAULT_BRANCH_ID) {
throw new StackAssertionError('getBranchConfigOverrideQuery is not implemented for branches other than the default one');
}
return migrateConfigOverride("branch", {});
🤖 Prompt for AI Agents
In apps/backend/src/lib/config.tsx around lines 146 to 148, the guard currently
throws a StackAssertionError when options.branchId !== DEFAULT_BRANCH_ID which
causes a 500 instead of allowing tenancy lookup to surface
KnownErrors.BranchDoesNotExist; remove the throw and return an empty override
(e.g., an empty config override object) in that branch so the function succeeds
and downstream tenancy logic can emit the intended 400.

return migrateConfigOverride("branch", {});
},
};
Expand Down
168 changes: 143 additions & 25 deletions apps/backend/src/lib/tenancies.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { globalPrismaClient, rawQuery } from "@/prisma-client";
import { globalPrismaClient, RawQuery, rawQuery } from "@/prisma-client";
import { Prisma } from "@prisma/client";
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { deepPlainEquals } from "@stackframe/stack-shared/dist/utils/objects";
import { getRenderedOrganizationConfigQuery } from "./config";
import { getProject } from "./projects";
import { getProject, getProjectQuery } from "./projects";

/**
* @deprecated YOU PROBABLY ALMOST NEVER WANT TO USE THIS, UNLESS YOU ACTUALLY NEED THE DEFAULT BRANCH ID. DON'T JUST USE THIS TO GET A TENANCY BECAUSE YOU DON'T HAVE ONE
Expand All @@ -16,7 +18,11 @@ import { getProject } from "./projects";
*/
export const DEFAULT_BRANCH_ID = "main";

export async function tenancyPrismaToCrud(prisma: Prisma.TenancyGetPayload<{}>) {
/**
* @deprecated UNUSED: This function is only kept for development mode validation in getTenancyFromProject.
* The old Prisma-based implementation, replaced by getTenancyFromProjectQuery which uses RawQuery.
*/
async function tenancyPrismaToCrudUnused(prisma: Prisma.TenancyGetPayload<{}>) {
if (prisma.hasNoOrganization && prisma.organizationId !== null) {
throw new StackAssertionError("Organization ID is not null for a tenancy with hasNoOrganization", { tenancyId: prisma.id, prisma });
}
Expand Down Expand Up @@ -44,7 +50,7 @@ export async function tenancyPrismaToCrud(prisma: Prisma.TenancyGetPayload<{}>)
};
}

export type Tenancy = Awaited<ReturnType<typeof tenancyPrismaToCrud>>;
export type Tenancy = Awaited<ReturnType<typeof tenancyPrismaToCrudUnused>>;

/**
* @deprecated This is a temporary function for the situation where every project-branch has exactly one tenancy. Later,
Expand All @@ -57,14 +63,22 @@ export function getSoleTenancyFromProjectBranch(project: Omit<ProjectsCrud["Admi
*/
export function getSoleTenancyFromProjectBranch(project: Omit<ProjectsCrud["Admin"]["Read"], "config"> | string, branchId: string, returnNullIfNotFound: boolean): Promise<Tenancy | null>;
export async function getSoleTenancyFromProjectBranch(projectOrId: Omit<ProjectsCrud["Admin"]["Read"], "config"> | string, branchId: string, returnNullIfNotFound: boolean = false): Promise<Tenancy | null> {
const res = await getTenancyFromProject(typeof projectOrId === 'string' ? projectOrId : projectOrId.id, branchId, null);
const res = await rawQuery(globalPrismaClient, getSoleTenancyFromProjectBranchQuery(projectOrId, branchId, true));
if (!res) {
if (returnNullIfNotFound) return null;
throw new StackAssertionError(`No tenancy found for project ${typeof projectOrId === 'string' ? projectOrId : projectOrId.id}`, { projectOrId });
}
return res;
}

/**
* @deprecated This is a temporary function for the situation where every project-branch has exactly one tenancy. Later,
* we will support multiple tenancies per project-branch, and all uses of this function will be refactored.
*/
export function getSoleTenancyFromProjectBranchQuery(project: Omit<ProjectsCrud["Admin"]["Read"], "config"> | string, branchId: string, returnNullIfNotFound: true): RawQuery<Promise<Tenancy | null>> {
return getTenancyFromProjectQuery(typeof project === 'string' ? project : project.id, branchId, null);
}

export async function getTenancy(tenancyId: string) {
if (tenancyId === "internal") {
throw new StackAssertionError("Tried to get tenancy with ID `internal`. This is a mistake because `internal` is only a valid identifier for projects.");
Expand All @@ -73,31 +87,135 @@ export async function getTenancy(tenancyId: string) {
where: { id: tenancyId },
});
if (!prisma) return null;
return await tenancyPrismaToCrud(prisma);
return await getTenancyFromProject(prisma.projectId, prisma.branchId, prisma.organizationId);
}

function getTenancyFromProjectQuery(projectId: string, branchId: string, organizationId: string | null): RawQuery<Promise<Tenancy | null>> {
return RawQuery.then(
RawQuery.all([
{
supportedPrismaClients: ["global"],
sql: organizationId === null
? Prisma.sql`
SELECT "Tenancy".*
FROM "Tenancy"
WHERE "Tenancy"."projectId" = ${projectId}
AND "Tenancy"."branchId" = ${branchId}
AND "Tenancy"."hasNoOrganization" = 'TRUE'
`
: Prisma.sql`
SELECT "Tenancy".*
FROM "Tenancy"
WHERE "Tenancy"."projectId" = ${projectId}
AND "Tenancy"."branchId" = ${branchId}
AND "Tenancy"."organizationId" = ${organizationId}
`,
postProcess: (queryResult) => {
if (queryResult.length > 1) {
throw new StackAssertionError(
`Expected 0 or 1 tenancies for project ${projectId}, branch ${branchId}, organization ${organizationId}, got ${queryResult.length}`,
{ queryResult }
);
}
if (queryResult.length === 0) {
return Promise.resolve(null);
}
return Promise.resolve(queryResult[0] as Prisma.TenancyGetPayload<{}>);
},
},
getProjectQuery(projectId),
getRenderedOrganizationConfigQuery({
projectId,
branchId,
organizationId,
}),
] as const),
async ([tenancyResultPromise, projectResultPromise, configPromise]) => {
const tenancyResult = await tenancyResultPromise;

if (!tenancyResult) return null;

const [projectResult, config] = await Promise.all([
projectResultPromise,
configPromise,
]);

if (!projectResult) {
throw new StackAssertionError("Project in tenancy not found", { projectId, tenancyId: tenancyResult.id });
}

// Validate tenancy consistency
if (tenancyResult.hasNoOrganization && tenancyResult.organizationId !== null) {
throw new StackAssertionError("Organization ID is not null for a tenancy with hasNoOrganization", {
tenancyId: tenancyResult.id,
tenancy: tenancyResult
});
}
if (!tenancyResult.hasNoOrganization && tenancyResult.organizationId === null) {
throw new StackAssertionError("Organization ID is null for a tenancy without hasNoOrganization", {
tenancyId: tenancyResult.id,
tenancy: tenancyResult
});
}

return {
id: tenancyResult.id,
config,
branchId: tenancyResult.branchId,
organization: tenancyResult.organizationId === null ? null : {
// TODO actual organization type
id: tenancyResult.organizationId,
},
project: projectResult,
};
}
);
}

/**
* @deprecated Not actually deprecated but if you're using this you're probably doing something wrong — ask Konsti for help
*
* (if Konsti is not around — unless you are editing the implementation of SmartRequestAuth, you should probably take the
* tenancy from the SmartRequest auth parameter instead of fetching your own. If you are editing the SmartRequestAuth
* implementation — carry on.)
*/
export async function getTenancyFromProject(projectId: string, branchId: string, organizationId: string | null) {
const prisma = await globalPrismaClient.tenancy.findUnique({
where: {
...(organizationId === null ? {
projectId_branchId_hasNoOrganization: {
projectId: projectId,
branchId: branchId,
hasNoOrganization: "TRUE",
}
} : {
projectId_branchId_organizationId: {
projectId: projectId,
branchId: branchId,
organizationId: organizationId,
}
}),
},
});
if (!prisma) return null;
return await tenancyPrismaToCrud(prisma);
// Use the new RawQuery implementation
const result = await rawQuery(globalPrismaClient, getTenancyFromProjectQuery(projectId, branchId, organizationId));

// In development mode, compare with the old implementation to ensure correctness
if (!getNodeEnvironment().includes("prod")) {
const prisma = await globalPrismaClient.tenancy.findUnique({
where: {
...(organizationId === null ? {
projectId_branchId_hasNoOrganization: {
projectId: projectId,
branchId: branchId,
hasNoOrganization: "TRUE",
}
} : {
projectId_branchId_organizationId: {
projectId: projectId,
branchId: branchId,
organizationId: organizationId,
}
}),
},
});
const oldResult = prisma ? await tenancyPrismaToCrudUnused(prisma) : null;

// Compare the two results
if (!deepPlainEquals(result, oldResult)) {
throw new StackAssertionError("getTenancyFromProject: new implementation does not match old implementation", {
projectId,
branchId,
organizationId,
newResult: result,
oldResult,
});
}
}

return result;
}

26 changes: 12 additions & 14 deletions apps/backend/src/route-handlers/smart-request.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getUser, getUserIfOnGlobalPrismaClientQuery } from "@/app/api/latest/us
import { getRenderedEnvironmentConfigQuery } from "@/lib/config";
import { checkApiKeySet, checkApiKeySetQuery } from "@/lib/internal-api-keys";
import { getProjectQuery, listManagedProjectIds } from "@/lib/projects";
import { DEFAULT_BRANCH_ID, Tenancy, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { DEFAULT_BRANCH_ID, Tenancy, getSoleTenancyFromProjectBranchQuery } from "@/lib/tenancies";
import { decodeAccessToken } from "@/lib/tokens";
import { globalPrismaClient, rawQueryAll } from "@/prisma-client";
import { KnownErrors } from "@stackframe/stack-shared";
Expand Down Expand Up @@ -248,23 +248,13 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque
isServerKeyValid: secretServerKey && requestType === "server" ? checkApiKeySetQuery(projectId, { secretServerKey }) : undefined,
isAdminKeyValid: superSecretAdminKey && requestType === "admin" ? checkApiKeySetQuery(projectId, { superSecretAdminKey }) : undefined,
project: getProjectQuery(projectId),
tenancy: getSoleTenancyFromProjectBranchQuery(projectId, branchId, true),
environmentRenderedConfig: getRenderedEnvironmentConfigQuery({ projectId, branchId }),
};
const queriesResults = await rawQueryAll(globalPrismaClient, bundledQueries);
const project = await queriesResults.project;
if (project === null) throw new KnownErrors.CurrentProjectNotFound(projectId); // this does allow one to probe whether a project exists or not, but that's fine
const environmentConfig = await queriesResults.environmentRenderedConfig;

// As explained above, as a performance optimization we already fetch the user from the global database optimistically
// If it turned out that the source-of-truth is not the global database, we'll fetch the user from the source-of-truth
// database instead.
const user = environmentConfig.sourceOfTruth.type === "hosted"
? queriesResults.userIfOnGlobalPrismaClient
: (userId ? await getUser({ userId, projectId, branchId }) : undefined);

// TODO HACK tenancy is not needed for /users/me, so let's not fetch it as a hack to make the endpoint faster. Once we
// refactor this stuff, we can fetch the tenancy alongside the user and won't need this anymore
const tenancy = req.method === "GET" && req.url.endsWith("/users/me") ? "tenancy not available in /users/me as a performance hack" as never : await getSoleTenancyFromProjectBranch(projectId, branchId, true);
if (project === null) throw new KnownErrors.CurrentProjectNotFound(projectId); // this does allow one to probe whether a project exists or not, but that's fine (it's worth the better error messages)
const tenancy = await queriesResults.tenancy;

if (developmentKeyOverride) {
if (!["development", "test"].includes(getNodeEnvironment()) && getEnvVariable("STACK_ALLOW_DEVELOPMENT_KEY_OVERRIDE_DESPITE_PRODUCTION", "") !== "this-is-dangerous") { // it's not actually that dangerous, but it changes the security model
Expand Down Expand Up @@ -299,9 +289,17 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque
}

if (!tenancy) {
// note that we only check branch existence here so you can't probe branches unless you have the project keys
throw new KnownErrors.BranchDoesNotExist(branchId);
}

// As explained above, as a performance optimization we already fetch the user from the global database optimistically
// If it turned out that the source-of-truth is not the global database, we'll fetch the user from the source-of-truth
// database instead.
const user = tenancy.config.sourceOfTruth.type === "hosted"
? queriesResults.userIfOnGlobalPrismaClient
: (userId ? await getUser({ userId, projectId, branchId }) : undefined);

return {
project,
branchId,
Expand Down
1 change: 1 addition & 0 deletions apps/e2e/tests/backend/backend-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function createMailbox(email?: string): Mailbox {

export type ProjectKeys = "no-project" | {
projectId: string,
branchId?: string,
publishableClientKey?: string,
secretServerKey?: string,
superSecretAdminKey?: string,
Expand Down
Loading
Loading