feat(auth): centralize ThothII permission enforcement
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user