Skip to content

Commit b8ea06f

Browse files
committed
Add internal project check to listManagedProjectIds
1 parent 5bfe1a7 commit b8ea06f

File tree

4 files changed

+47
-5
lines changed

4 files changed

+47
-5
lines changed

apps/backend/src/app/api/latest/ai/query/[mode]/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ export const POST = createSmartRouteHandler({
4747

4848
// Verify user has access to the target project
4949
if (projectId != null) {
50-
const user = fullReq.auth?.user;
50+
if (fullReq.auth?.project.id !== "internal") {
51+
throw new StatusError(StatusError.Forbidden, "You do not have access to this project");
52+
}
53+
const user = fullReq.auth.user;
5154
if (user == null) {
5255
throw new StatusError(StatusError.Forbidden, "You do not have access to this project");
5356
}

apps/backend/src/app/api/latest/internal/projects/crud.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { renderedOrganizationConfigToProjectCrud } from "@/lib/config";
2-
import { getPrismaClientForTenancy } from "@/prisma-client";
32
import { createOrUpdateProjectWithLegacyConfig, getProjectQuery, listManagedProjectIds } from "@/lib/projects";
43
import { ensureTeamMembershipExists } from "@/lib/request-checks";
54
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
6-
import { globalPrismaClient, rawQueryAll } from "@/prisma-client";
5+
import { getPrismaClientForTenancy, globalPrismaClient, rawQueryAll } from "@/prisma-client";
76
import { createCrudHandlers } from "@/route-handlers/crud-handler";
87
import { KnownErrors } from "@stackframe/stack-shared";
98
import { adminUserProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
@@ -17,14 +16,17 @@ export const adminUserProjectsCrudHandlers = createLazyProxy(() => createCrudHan
1716
projectId: projectIdSchema.defined(),
1817
}),
1918
onPrepare: async ({ auth }) => {
19+
if (auth.project.id !== "internal") {
20+
throw new KnownErrors.ExpectedInternalProject();
21+
}
2022
if (!auth.user) {
2123
throw new KnownErrors.UserAuthenticationRequired;
2224
}
25+
},
26+
onCreate: async ({ auth, data }) => {
2327
if (auth.project.id !== "internal") {
2428
throw new KnownErrors.ExpectedInternalProject();
2529
}
26-
},
27-
onCreate: async ({ auth, data }) => {
2830
const user = auth.user ?? throwErr('auth.user is required');
2931
const prisma = await getPrismaClientForTenancy(auth.tenancy);
3032
await ensureTeamMembershipExists(prisma, {
@@ -51,6 +53,12 @@ export const adminUserProjectsCrudHandlers = createLazyProxy(() => createCrudHan
5153
};
5254
},
5355
onList: async ({ auth }) => {
56+
if (auth.project.id !== "internal") {
57+
throw new KnownErrors.ExpectedInternalProject();
58+
}
59+
if (!auth.user) {
60+
throw new KnownErrors.UserAuthenticationRequired();
61+
}
5462
const projectIds = await listManagedProjectIds(auth.user ?? throwErr('auth.user is required'));
5563
const projectsRecord = await rawQueryAll(globalPrismaClient, typedFromEntries(projectIds.map((id, index) => [index, getProjectQuery(id)])));
5664
const projects = (await Promise.all(typedEntries(projectsRecord).map(async ([_, project]) => await project))).filter(isNotNull);

apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,26 @@ describe("AI Query Endpoint - Validation", () => {
132132
expect(response.body).toEqual(expect.stringContaining("Invalid tool names"));
133133
});
134134

135+
it("rejects project-scoped AI requests outside internal project auth context", async ({ expect }) => {
136+
const { projectId } = await Project.createAndSwitch();
137+
138+
const response = await niceBackendFetch("/api/v1/ai/query/generate", {
139+
method: "POST",
140+
accessType: "admin",
141+
body: {
142+
quality: "smart",
143+
speed: "fast",
144+
tools: [],
145+
systemPrompt: "command-center-ask-ai",
146+
messages: [{ role: "user", content: "test" }],
147+
projectId,
148+
},
149+
});
150+
151+
expect(response.status).toBe(403);
152+
expect(response.body).toEqual(expect.stringContaining("You do not have access to this project"));
153+
});
154+
135155
it("rejects missing systemPrompt field", async ({ expect }) => {
136156
const response = await niceBackendFetch("/api/v1/ai/query/generate", {
137157
method: "POST",

apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ it("is not allowed to list all current projects without signing in", async ({ ex
4343
`);
4444
});
4545

46+
it("is not allowed to list internal projects from a non-internal project context", async ({ expect }) => {
47+
await Project.createAndSwitch();
48+
const response = await niceBackendFetch("/api/v1/internal/projects", { accessType: "admin" });
49+
expect(response.status).toBe(401);
50+
expect(response.headers.get("x-stack-known-error")).toBe("EXPECTED_INTERNAL_PROJECT");
51+
expect(response.body).toMatchObject({
52+
code: "EXPECTED_INTERNAL_PROJECT",
53+
error: "The project ID is expected to be internal.",
54+
});
55+
});
56+
4657
it("lists all current projects (empty list)", async ({ expect }) => {
4758
await Auth.fastSignUp();
4859
const response = await niceBackendFetch("/api/v1/internal/projects", { accessType: "client" });

0 commit comments

Comments
 (0)