From b8ea06f73d9f6dfd9df1aeddeb8b7c78d042b0ee Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 27 Mar 2026 14:48:35 -0700 Subject: [PATCH] Add internal project check to listManagedProjectIds --- .../app/api/latest/ai/query/[mode]/route.ts | 5 ++++- .../app/api/latest/internal/projects/crud.tsx | 18 ++++++++++++----- .../backend/endpoints/api/v1/ai-query.test.ts | 20 +++++++++++++++++++ .../api/v1/internal/projects.test.ts | 11 ++++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts index aded240d5..260879620 100644 --- a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts +++ b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts @@ -47,7 +47,10 @@ export const POST = createSmartRouteHandler({ // Verify user has access to the target project if (projectId != null) { - const user = fullReq.auth?.user; + if (fullReq.auth?.project.id !== "internal") { + throw new StatusError(StatusError.Forbidden, "You do not have access to this project"); + } + const user = fullReq.auth.user; if (user == null) { throw new StatusError(StatusError.Forbidden, "You do not have access to this project"); } diff --git a/apps/backend/src/app/api/latest/internal/projects/crud.tsx b/apps/backend/src/app/api/latest/internal/projects/crud.tsx index 52b57531d..7fb39aa88 100644 --- a/apps/backend/src/app/api/latest/internal/projects/crud.tsx +++ b/apps/backend/src/app/api/latest/internal/projects/crud.tsx @@ -1,9 +1,8 @@ import { renderedOrganizationConfigToProjectCrud } from "@/lib/config"; -import { getPrismaClientForTenancy } from "@/prisma-client"; import { createOrUpdateProjectWithLegacyConfig, getProjectQuery, listManagedProjectIds } from "@/lib/projects"; import { ensureTeamMembershipExists } from "@/lib/request-checks"; import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; -import { globalPrismaClient, rawQueryAll } from "@/prisma-client"; +import { getPrismaClientForTenancy, globalPrismaClient, rawQueryAll } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adminUserProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; @@ -17,14 +16,17 @@ export const adminUserProjectsCrudHandlers = createLazyProxy(() => createCrudHan projectId: projectIdSchema.defined(), }), onPrepare: async ({ auth }) => { - if (!auth.user) { - throw new KnownErrors.UserAuthenticationRequired; - } if (auth.project.id !== "internal") { throw new KnownErrors.ExpectedInternalProject(); } + if (!auth.user) { + throw new KnownErrors.UserAuthenticationRequired; + } }, onCreate: async ({ auth, data }) => { + if (auth.project.id !== "internal") { + throw new KnownErrors.ExpectedInternalProject(); + } const user = auth.user ?? throwErr('auth.user is required'); const prisma = await getPrismaClientForTenancy(auth.tenancy); await ensureTeamMembershipExists(prisma, { @@ -51,6 +53,12 @@ export const adminUserProjectsCrudHandlers = createLazyProxy(() => createCrudHan }; }, onList: async ({ auth }) => { + if (auth.project.id !== "internal") { + throw new KnownErrors.ExpectedInternalProject(); + } + if (!auth.user) { + throw new KnownErrors.UserAuthenticationRequired(); + } const projectIds = await listManagedProjectIds(auth.user ?? throwErr('auth.user is required')); const projectsRecord = await rawQueryAll(globalPrismaClient, typedFromEntries(projectIds.map((id, index) => [index, getProjectQuery(id)]))); const projects = (await Promise.all(typedEntries(projectsRecord).map(async ([_, project]) => await project))).filter(isNotNull); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts index cb91d97b5..f02c41ffc 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts @@ -132,6 +132,26 @@ describe("AI Query Endpoint - Validation", () => { expect(response.body).toEqual(expect.stringContaining("Invalid tool names")); }); + it("rejects project-scoped AI requests outside internal project auth context", async ({ expect }) => { + const { projectId } = await Project.createAndSwitch(); + + const response = await niceBackendFetch("/api/v1/ai/query/generate", { + method: "POST", + accessType: "admin", + body: { + quality: "smart", + speed: "fast", + tools: [], + systemPrompt: "command-center-ask-ai", + messages: [{ role: "user", content: "test" }], + projectId, + }, + }); + + expect(response.status).toBe(403); + expect(response.body).toEqual(expect.stringContaining("You do not have access to this project")); + }); + it("rejects missing systemPrompt field", async ({ expect }) => { const response = await niceBackendFetch("/api/v1/ai/query/generate", { method: "POST", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts index cbd8c52ae..de7dd463e 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts @@ -43,6 +43,17 @@ it("is not allowed to list all current projects without signing in", async ({ ex `); }); +it("is not allowed to list internal projects from a non-internal project context", async ({ expect }) => { + await Project.createAndSwitch(); + const response = await niceBackendFetch("/api/v1/internal/projects", { accessType: "admin" }); + expect(response.status).toBe(401); + expect(response.headers.get("x-stack-known-error")).toBe("EXPECTED_INTERNAL_PROJECT"); + expect(response.body).toMatchObject({ + code: "EXPECTED_INTERNAL_PROJECT", + error: "The project ID is expected to be internal.", + }); +}); + it("lists all current projects (empty list)", async ({ expect }) => { await Auth.fastSignUp(); const response = await niceBackendFetch("/api/v1/internal/projects", { accessType: "client" });