feat(auth): centralize ThothII permission enforcement

This commit is contained in:
2026-08-16 17:52:03 +02:00
parent 23ac75ce1d
commit 2e0489ce22
20 changed files with 330 additions and 93 deletions
+3 -1
View File
@@ -2,6 +2,7 @@ import { readdirSync } from "node:fs";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import type { PiModel } from "../pi/list-models.js";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
export type ListModelsFn = () => Promise<PiModel[]>;
@@ -26,7 +27,8 @@ export function metaRoutes(
app: FastifyInstance,
deps: { harnessDir: string; listModels?: ListModelsFn },
): void {
app.get("/models", async () => {
app.get("/models", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply;
const fn = deps.listModels ?? (async () => []);
try {
return { models: await fn() };
+5 -34
View File
@@ -1,11 +1,10 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { getPrincipal } from "../auth/auth.js";
import type { AppConfig } from "../config.js";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
import { PiManagementError, type PiManagementService } from "../pi/management.js";
export function piManagementRoutes(
app: FastifyInstance,
deps: { config: AppConfig; service: PiManagementService },
deps: { service: PiManagementService },
): void {
app.get("/pi-management/status", async (request, reply) => run(request, reply, deps, () => deps.service.status()));
app.get("/pi-management/options", async (request, reply) => run(request, reply, deps, () => deps.service.options()));
@@ -22,17 +21,11 @@ export function piManagementRoutes(
async function run<T>(
request: FastifyRequest,
reply: FastifyReply,
deps: { config: AppConfig; service: PiManagementService },
deps: { service: PiManagementService },
action: () => Promise<T>,
): Promise<T | FastifyReply> {
const principal = getPrincipal(request);
if (!managementAllowed(deps.config, principal.isAdmin)) {
return reply.code(403).send({ code: "pi_management_forbidden", error: "Pi management is not permitted" });
}
if (deps.config.authMode === "none" && isManagementWrite(request.method)
&& !sameOriginOrNonBrowser(request)) {
return reply.code(403).send({ code: "pi_management_forbidden", error: "Pi management is not permitted" });
}
const principal = requirePermission(request, reply, "pi.manage");
if (!isPrincipalContext(principal)) return principal;
try {
return await action();
} catch (error) {
@@ -43,25 +36,3 @@ async function run<T>(
return reply.code(503).send({ code: "pi_management_unavailable", error: "Pi management is unavailable" });
}
}
function managementAllowed(config: AppConfig, isAdmin: boolean): boolean {
return (config.authMode === "none" && !config.publicExposure)
|| (config.authMode === "upstream" && isAdmin);
}
function isManagementWrite(method: string): boolean {
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
}
function sameOriginOrNonBrowser(request: FastifyRequest): boolean {
const origin = request.headers.origin;
if (origin === undefined) return true;
if (typeof origin !== "string" || typeof request.headers.host !== "string") return false;
try {
const supplied = new URL(origin);
const expected = new URL(`${request.protocol}://${request.headers.host}`);
return supplied.origin === expected.origin;
} catch {
return false;
}
}
+38 -16
View File
@@ -10,6 +10,7 @@ import type { ListModelsFn } from "./meta.js";
import type { WorkspaceRegistry } from "../workspaces/registry.js";
import { validateOperationalWorkspace, type WorkspaceDescriptor } from "../workspaces/schema.js";
import type { MaintenanceBarrier } from "../runtime/maintenance-gate.js";
import { hasPermission, isPrincipalContext, requirePermission } from "../auth/authorization.js";
const BOOTSTRAP_FAILURE_MESSAGE =
"Session startup failed. Check configuration and connectivity, then Resume the session.";
@@ -76,6 +77,12 @@ export function sessionRoutes(
const runner = d.tht as any;
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
};
const ownershipPrincipal = (principal: PrincipalContext, permission: "session.read_all" | "session.manage_all") => ({
...principal,
// The harness transition remains isAdmin-based, but only the relevant all-session
// permission can enable its RLS bypass.
isAdmin: hasPermission(principal, permission),
});
const optionsWithRuntimeConfig = (runner: any, workspaceConfigPath: string | undefined, options: any) => (
workspaceConfigPath && typeof runner.acquireWorkspaceRuntime === "function"
@@ -94,6 +101,12 @@ export function sessionRoutes(
if (!release) return maintenanceReply(reply);
admissionLeases.set(req, release);
});
app.addHook("preHandler", async (req, reply) => {
if (req.url === "/runtime/prewarm" || req.url === "/sessions" || req.url.startsWith("/sessions/")) {
const principal = requirePermission(req, reply, "session.use");
if (!isPrincipalContext(principal)) return principal;
}
});
app.addHook("onResponse", async (req) => { admissionLeases.get(req)?.(); });
/** Include retained historical descriptors so removed workspaces remain resumable. */
@@ -127,8 +140,10 @@ export function sessionRoutes(
* Find a session by asking every active registry snapshot, never by using the installation
* default. `tht` applies RLS for the supplied principal, so a foreign ID remains a 404.
*/
const locateSession = async (principal: PrincipalContext, id: string): Promise<LocatedSession | undefined> => {
const runner = runnerFor(principal);
const locateSession = async (
principal: PrincipalContext, id: string, permission: "session.read_all" | "session.manage_all" = "session.read_all",
): Promise<LocatedSession | undefined> => {
const runner = runnerFor(ownershipPrincipal(principal, permission));
// Dependency-injected runners in legacy route tests may model only the mutation under test.
if (typeof runner.sessionShow !== "function") return { manifest: {}, workspaceConfigPath: "" };
const legacySession = async (): Promise<LocatedSession | undefined> => {
@@ -183,8 +198,9 @@ export function sessionRoutes(
};
/** RLS makes a foreign session indistinguishable from a missing one. */
const authorize = async (principal: PrincipalContext, id: string): Promise<LocatedSession | undefined> =>
await locateSession(principal, id);
const authorize = async (
principal: PrincipalContext, id: string, permission: "session.read_all" | "session.manage_all" = "session.read_all",
): Promise<LocatedSession | undefined> => await locateSession(principal, id, permission);
const storageFailure = (reply: any) => reply.code(503).send({ error: "session storage is unavailable" });
const lifecycleFailure = (reply: any, error: unknown) =>
@@ -467,10 +483,15 @@ export function sessionRoutes(
const principal = getPrincipal(req);
const scope = (req.query as { scope?: string }).scope ?? "mine";
if (scope !== "mine" && scope !== "all") return reply.code(400).send({ error: "scope must be mine or all" });
if (scope === "all" && !principal.isAdmin) return reply.code(403).send({ error: "admin scope required" });
if (scope === "all") {
const allPrincipal = requirePermission(req, reply, "session.read_all");
if (!isPrincipalContext(allPrincipal)) return allPrincipal;
}
try {
// Admin RLS is deliberately disabled for a normal 'mine' listing.
const scopedPrincipal = scope === "mine" ? { ...principal, isAdmin: false } : principal;
const scopedPrincipal = scope === "mine"
? { ...principal, isAdmin: false }
: ownershipPrincipal(principal, "session.read_all");
const runner = runnerFor(scopedPrincipal);
const revisions = await sessionRevisions();
const lists = await Promise.all(revisions
@@ -483,7 +504,8 @@ export function sessionRoutes(
// Only an administrator-visible complete list (or the single local principal) is safe
// input for retention. A remote per-user view can never discard another principal's pin.
const reconcileSnapshotRetention = (d.workspaceRegistry as Partial<WorkspaceRegistry>).reconcileSnapshotRetention;
const hasCompleteRetentionView = (scope === "all" && principal.isAdmin) || principal.issuer === "local";
const hasCompleteRetentionView = (scope === "all" || principal.issuer === "local")
&& hasPermission(principal, "session.read_all");
if (hasCompleteRetentionView && typeof reconcileSnapshotRetention === "function") {
const retained = [...new Set(list
.filter((row) => row.status !== "finalized" && !row.archived && typeof row.workspace_revision === "string")
@@ -512,7 +534,7 @@ export function sessionRoutes(
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
if (!await authorize(principal, id)) return reply.code(404).send({ error: "session not found" });
if (!await authorize(principal, id, "session.manage_all")) return reply.code(404).send({ error: "session not found" });
} catch (error) { return lifecycleFailure(reply, error); }
const rt = d.mgr.get(id);
if (!rt) return reply.code(404).send({ error: "sessione non attiva" });
@@ -525,7 +547,7 @@ export function sessionRoutes(
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
if (!await authorize(principal, id)) return reply.code(404).send({ error: "session not found" });
if (!await authorize(principal, id, "session.manage_all")) return reply.code(404).send({ error: "session not found" });
} catch (error) { return lifecycleFailure(reply, error); }
const rt = d.mgr.get(id);
if (!rt) return reply.code(404).send({ error: "sessione non attiva" });
@@ -539,7 +561,7 @@ export function sessionRoutes(
let settings: Settings;
let located: LocatedSession | undefined;
try {
located = await locateSession(principal, id);
located = await locateSession(principal, id, "session.manage_all");
} catch { return storageFailure(reply); }
if (!located) return reply.code(404).send({ error: "session not found" });
const manifest = located.manifest;
@@ -647,7 +669,7 @@ export function sessionRoutes(
return withSessionLifecycle(id, async () => {
let session: LocatedSession | undefined;
try {
session = await authorize(principal, id);
session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
} catch (error) { return lifecycleFailure(reply, error); }
// Invalidate the live generation before persistence can yield. Otherwise its deferred
@@ -708,7 +730,7 @@ export function sessionRoutes(
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const session = await authorize(principal, id);
const session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).setName(id, (req.body as any).name, session.workspaceConfigPath);
} catch (error) { return lifecycleFailure(reply, error); }
@@ -718,7 +740,7 @@ export function sessionRoutes(
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const session = await authorize(principal, id);
const session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).setGroup(id, (req.body as any).group, session.workspaceConfigPath);
} catch (error) { return lifecycleFailure(reply, error); }
@@ -728,7 +750,7 @@ export function sessionRoutes(
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const session = await authorize(principal, id);
const session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).archive(id, session.workspaceConfigPath);
} catch (error) { return lifecycleFailure(reply, error); }
@@ -738,7 +760,7 @@ export function sessionRoutes(
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const session = await authorize(principal, id);
const session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).unarchive(id, session.workspaceConfigPath);
} catch (error) { return lifecycleFailure(reply, error); }
@@ -750,7 +772,7 @@ export function sessionRoutes(
return withSessionLifecycle(id, async () => {
let session: LocatedSession | undefined;
try {
session = await authorize(principal, id);
session = await authorize(principal, id, "session.manage_all");
if (!session) return reply.code(404).send({ error: "session not found" });
} catch (error) { return lifecycleFailure(reply, error); }
const current = d.mgr.get(id);
+7 -3
View File
@@ -2,8 +2,8 @@ import type { FastifyInstance } from "fastify";
import type { AppConfig } from "../config.js";
import type { Settings } from "../settings/settings-store.js";
import { listWorkspaces, type ListModelsFn } from "./meta.js";
import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
/** Merge stored settings over env/first-workspace defaults. */
export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
@@ -24,14 +24,18 @@ export function settingsRoutes(
},
): void {
app.get("/settings", async (req, reply) => {
const principal = requirePermission(req, reply, "session.use");
if (!isPrincipalContext(principal)) return principal;
try {
return await deps.getSettings(getPrincipal(req));
return await deps.getSettings(principal);
} catch {
return reply.code(503).send({ error: "settings storage is unavailable" });
}
});
app.put("/settings", async (req, reply) => {
const principal = requirePermission(req, reply, "settings.manage");
if (!isPrincipalContext(principal)) return principal;
const b = (req.body ?? {}) as Settings;
if (b.model) {
let available: { provider: string; id: string }[] = [];
@@ -52,7 +56,7 @@ export function settingsRoutes(
try {
// Retain this endpoint as a validating compatibility surface for older clients, but do
// not write anonymous users' choices to shared server storage.
return await deps.getSettings(getPrincipal(req));
return await deps.getSettings(principal);
} catch {
return reply.code(503).send({ error: "settings storage is unavailable" });
}
+7 -3
View File
@@ -1,9 +1,9 @@
import type { FastifyInstance } from "fastify";
import type { ThtRunner } from "../tht/tht-runner.js";
import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
import type { Settings } from "../settings/settings-store.js";
import type { WorkspaceRegistry } from "../workspaces/registry.js";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
export function sqlRoutes(app: FastifyInstance, deps: {
tht: ThtRunner; getSettings: (principal: PrincipalContext) => Promise<Settings>;
@@ -54,7 +54,9 @@ export function sqlRoutes(app: FastifyInstance, deps: {
let principal: PrincipalContext;
let workspace: string | undefined;
try {
principal = getPrincipal(req);
const authorized = requirePermission(req, reply, "session.use");
if (!isPrincipalContext(authorized)) return authorized;
principal = authorized;
const settings = await deps.getSettings(principal);
const located = await locate(principal, id, settings.workspace);
if (!located) return reply.code(404).send({ error: "session not found" });
@@ -74,7 +76,9 @@ export function sqlRoutes(app: FastifyInstance, deps: {
let principal: PrincipalContext;
let workspace: string | undefined;
try {
principal = getPrincipal(req);
const authorized = requirePermission(req, reply, "session.use");
if (!isPrincipalContext(authorized)) return authorized;
principal = authorized;
const settings = await deps.getSettings(principal);
const located = await locate(principal, id, settings.workspace);
if (!located) return reply.code(404).send({ error: "session not found" });
+13 -3
View File
@@ -17,6 +17,7 @@ import {
} from "../workspaces/schema.js";
import type { RuntimeBindings } from "../workspaces/runtime-renderer.js";
import type { WorkspaceDiagnostics } from "../workspaces/diagnostics.js";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
export type WorkspaceDiagnoser = (
workspace: WorkspaceDescriptor,
@@ -91,7 +92,8 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
};
};
app.get("/workspace-registry/status", async (_request, reply) => {
app.get("/workspace-registry/status", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "workspace.manage"))) return reply;
try {
return await deps.registry.bootstrap();
} catch (error) {
@@ -99,7 +101,8 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
}
});
app.post("/workspace-registry/pull", async (_request, reply) => {
app.post("/workspace-registry/pull", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "workspace.manage"))) return reply;
try {
return await deps.registry.pull();
} catch (error) {
@@ -107,7 +110,8 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
}
});
app.get("/workspaces", async (_request, reply) => {
app.get("/workspaces", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply;
try {
const records = await deps.registry.listCatalog();
return await Promise.all(records.map(async (record) => {
@@ -129,6 +133,7 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
});
app.get("/workspaces/:id", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply;
try {
const { id } = z.object({ id: workspaceId }).parse(request.params);
return await deps.registry.read(id);
@@ -138,6 +143,7 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
});
app.post("/workspaces/validate", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "workspace.manage"))) return reply;
try {
const { workspace } = workspacePayload.parse(request.body);
const canonical = validateWorkspaceDescriptor(workspace);
@@ -148,6 +154,7 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
});
app.get("/workspaces/:id/runtime-configuration", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply;
try {
const { id } = z.object({ id: workspaceId }).parse(request.params);
return await runtimeConfiguration(id);
@@ -157,6 +164,7 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
});
app.put("/workspaces/:id/secrets", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "workspace.secrets.manage"))) return reply;
try {
const { id } = z.object({ id: workspaceId }).parse(request.params);
const { values } = secretValuesPayload.parse(request.body);
@@ -176,6 +184,7 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
});
app.delete("/workspaces/:id/secrets/:requirementId", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "workspace.secrets.manage"))) return reply;
try {
const { id, requirementId } = z.object({
id: workspaceId,
@@ -194,6 +203,7 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
});
app.post("/workspaces/:id/test", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "workspace.manage"))) return reply;
try {
const { id } = z.object({ id: workspaceId }).parse(request.params);
const { workspace } = await deps.registry.read(id);