Add internal project check to listManagedProjectIds

This commit is contained in:
Konstantin Wohlwend 2026-03-27 14:48:35 -07:00
parent 5bfe1a79ce
commit b8ea06f73d
4 changed files with 48 additions and 6 deletions

View File

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

View File

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

View File

@ -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",

View File

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