feat(backend): enforce user-owned sessions

This commit is contained in:
User
2026-07-16 18:32:52 +02:00
parent 72db6b823d
commit 458eb13c89
15 changed files with 626 additions and 105 deletions
+22 -12
View File
@@ -1,38 +1,48 @@
import { test, expect } from "vitest";
import Fastify from "fastify";
import { authPreHandler, getUser } from "../src/auth/auth.js";
import { authPreHandler, getPrincipal } from "../src/auth/auth.js";
test("mode none assegna dev@local", async () => {
test("local mode resolves a stable local principal", async () => {
const app = Fastify();
app.addHook("preHandler", authPreHandler("none"));
app.get("/me", async (req) => getUser(req));
expect((await app.inject({ method: "GET", url: "/me" })).json()).toEqual({
id: "dev@local",
app.get("/me", async (req) => getPrincipal(req));
expect((await app.inject({ method: "GET", url: "/me" })).json()).toMatchObject({
issuer: "local",
subject: expect.any(String),
isAdmin: false,
});
});
test("mode mock legge l'header", async () => {
test("mock mode makes a principal from the test header", async () => {
const app = Fastify();
app.addHook("preHandler", authPreHandler("mock"));
app.get("/me", async (req) => getUser(req));
app.get("/me", async (req) => getPrincipal(req));
const res = await app.inject({
method: "GET",
url: "/me",
headers: { "x-mock-user": "alice" },
});
expect(res.json()).toEqual({ id: "alice" });
expect(res.json()).toEqual({ issuer: "mock", subject: "alice", displayName: "alice", isAdmin: false });
});
test("upstream mode requires the authenticated proxy identity header", async () => {
test("upstream mode accepts only normalized proxy principal headers", async () => {
const app = Fastify();
app.addHook("preHandler", authPreHandler("upstream"));
app.get("/me", async (req) => getUser(req));
app.get("/me", async (req) => getPrincipal(req));
expect((await app.inject({ method: "GET", url: "/me" })).statusCode).toBe(401);
const authenticated = await app.inject({
method: "GET",
url: "/me",
headers: { "x-authenticated-user": "alice@example.test" },
headers: {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "42",
"x-thoth-principal-display-name": "Alice",
"x-thoth-is-admin": "1",
"x-authenticated-user": "must-not-be-used",
},
});
expect(authenticated.json()).toEqual({
issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true,
});
expect(authenticated.json()).toEqual({ id: "alice@example.test" });
});
+3 -1
View File
@@ -22,7 +22,9 @@ test("GET /health remains available to container probes in upstream auth mode",
});
test("SSE response headers are flushed before the first event", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }), {
thtRunner: { sessionShow: async () => ({ id: "header-probe" }) } as any,
});
await app.listen({ port: 0, host: "127.0.0.1" });
const port = (app.server.address() as { port: number }).port;
const controller = new AbortController();
+89 -2
View File
@@ -28,6 +28,91 @@ function deferred<T = void>() {
return { promise, resolve, reject };
}
const aliceHeaders = {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "alice",
"x-thoth-principal-display-name": "Alice",
"x-thoth-is-admin": "0",
};
test("upstream requests without a principal fail before a Pi runtime can be created", async () => {
let created = false;
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
mgr: { createFor: () => { created = true; throw new Error("must not spawn"); } } as any,
thtRunner: {} as any,
});
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
expect(response.statusCode).toBe(401);
expect(created).toBe(false);
});
test("session routes conceal foreign or missing sessions and deny SSE before it subscribes", async () => {
let subscribed = false;
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
withPrincipal: () => ({ sessionShow: async () => null }),
} as any,
hub: { subscribe: () => { subscribed = true; return () => {}; } } as any,
});
const document = await app.inject({ method: "GET", url: "/sessions/foreign/documents", headers: aliceHeaders });
const events = await app.inject({ method: "GET", url: "/sessions/foreign/events", headers: aliceHeaders });
expect(document.statusCode).toBe(404);
expect(events.statusCode).toBe(404);
expect(subscribed).toBe(false);
});
test("session listing permits all scope only to admins", async () => {
const seen: boolean[] = [];
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
withPrincipal: (principal: any) => ({
sessionList: async () => { seen.push(principal.isAdmin); return [{ id: "s1" }]; },
}),
} as any,
});
const regularAll = await app.inject({ method: "GET", url: "/sessions?scope=all", headers: aliceHeaders });
const mine = await app.inject({ method: "GET", url: "/sessions?scope=mine", headers: aliceHeaders });
const adminAll = await app.inject({
method: "GET", url: "/sessions?scope=all",
headers: { ...aliceHeaders, "x-thoth-is-admin": "1" },
});
expect(regularAll.statusCode).toBe(403);
expect(mine.statusCode).toBe(200);
expect(adminAll.statusCode).toBe(200);
expect(seen).toEqual([false, true]);
});
test("new sessions are created through the authenticated principal, not a client owner field", async () => {
let principal: any;
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
withPrincipal: (p: any) => {
principal = p;
return { sessionNew: async () => ({ id: "owned" }), searchPack: async () => {} };
},
} as any,
readiness: { ensure: async () => ({ ok: true }) } as any,
mgr: {
createFor: () => ({ bridge: { onClientEvent: () => {} } }),
configure: async () => {}, start: () => {}, get: () => undefined,
} as any,
getSettings: () => ({ workspace: "w" }) as any,
});
const response = await app.inject({
method: "POST", url: "/sessions", headers: aliceHeaders,
payload: { question: "q", owner: "mallory" },
});
expect(response.statusCode).toBe(200);
expect(principal).toMatchObject({ issuer: "portal", subject: "alice" });
});
test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => {
const modelKey = path.join(os.tmpdir(), `thoth-model-key-${process.pid}`);
writeFileSync(modelKey, "test-model-key", { mode: 0o600 });
@@ -455,7 +540,9 @@ test("concurrent cold Resume requests serialize and create one runtime", async (
expect(firstResponse.json()).toEqual({ id: "s1", alreadyActive: false });
expect(secondResponse.json()).toEqual({ id: "s1", alreadyActive: true });
expect({ showCalls, readinessCalls, reopenCalls, createCalls, clearCalls }).toEqual({
showCalls: 1,
// Each caller is authorized against repository ownership, including the request which
// finds the runtime already active after waiting on the lifecycle lock.
showCalls: 2,
readinessCalls: 1,
reopenCalls: 1,
createCalls: 1,
@@ -942,7 +1029,7 @@ test("Delete followed by Resume cannot resurrect the deleted session", async ()
await deleteResponse;
const resumed = await resumeResponse;
expect(resumed.statusCode).toBe(500);
expect(resumed.statusCode).toBe(503);
expect(current).toBeUndefined();
expect(createCalls).toBe(0);
});
+29
View File
@@ -48,6 +48,35 @@ test("PUT /settings persists and GET reads it back", async () => {
}
});
test("settings are isolated by the authenticated repository principal", async () => {
const preferences = new Map<string, any>();
const runner = {
withPrincipal: (principal: any) => ({
preferencesGet: async () => preferences.get(principal.subject) ?? {},
preferencesSet: async (next: any) => { preferences.set(principal.subject, next); },
}),
};
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
thtRunner: runner as any,
listModels: async () => [],
});
const headers = (subject: string) => ({
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": subject,
"x-thoth-is-admin": "0",
});
await app.inject({
method: "PUT", url: "/settings", headers: headers("alice"),
payload: { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" },
});
const alice = await app.inject({ method: "GET", url: "/settings", headers: headers("alice") });
const bob = await app.inject({ method: "GET", url: "/settings", headers: headers("bob") });
expect(alice.json()).toMatchObject({ workspace: "psd", thinking: "high" });
expect(bob.json()).not.toMatchObject({ workspace: "psd", thinking: "high" });
});
test("PUT /settings rejects an unknown model when a model list is available", async () => {
const { app, dir } = appWithTmpSettings({}, {
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],