feat(auth): centralize ThothII permission enforcement
This commit is contained in:
+4
-4
@@ -7,7 +7,7 @@ import { ThtRunner } from "./tht/tht-runner.js";
|
||||
import { PiProcessManager } from "./pi/pi-process-manager.js";
|
||||
import { SseHub } from "./sse/sse-hub.js";
|
||||
import { authPreHandler } from "./auth/auth.js";
|
||||
import { getPrincipal } from "./auth/auth.js";
|
||||
import { requirePermission } from "./auth/authorization.js";
|
||||
import type { PrincipalContext } from "./auth/principal.js";
|
||||
import { sessionRoutes } from "./routes/sessions.js";
|
||||
import { sqlRoutes } from "./routes/sql.js";
|
||||
@@ -140,7 +140,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
if (config.authMode === "local" || config.authMode === "oidc") {
|
||||
throw new Error("configured authentication mode is not implemented");
|
||||
}
|
||||
const authenticate = authPreHandler(config.authMode);
|
||||
const authenticate = authPreHandler(config.authMode, config.publicExposure);
|
||||
app.addHook("preHandler", async (req, reply) => {
|
||||
// Process readiness is intentionally unauthenticated for local container/proxy probes.
|
||||
if (req.url === "/health" || req.url === "/health/dwh") return;
|
||||
@@ -167,7 +167,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
}
|
||||
return { ok: true, detail: "workspace diagnostics own DWH reachability" };
|
||||
});
|
||||
app.get("/me", async (req) => getPrincipal(req));
|
||||
app.get("/me", async (req, reply) => requirePermission(req, reply, "session.use"));
|
||||
sessionRoutes(app, {
|
||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
|
||||
dwhPrecheck: config.dwhPrecheck,
|
||||
@@ -211,7 +211,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
secretStore: workspaceSecretStore,
|
||||
});
|
||||
settingsRoutes(app, { cfg: config, listModels, getSettings });
|
||||
piManagementRoutes(app, { config, service: piManagement });
|
||||
piManagementRoutes(app, { service: piManagement });
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
|
||||
import { rolesToPermissions } from "./config.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest { principal?: PrincipalContext }
|
||||
}
|
||||
|
||||
export function authPreHandler(mode: "none" | "mock" | "upstream") {
|
||||
export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposure = false) {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (mode === "none") {
|
||||
req.principal = localPrincipal();
|
||||
req.principal = localPrincipal(publicExposure);
|
||||
} else if (mode === "mock") {
|
||||
const subject = typeof req.headers["x-mock-user"] === "string" ? req.headers["x-mock-user"].trim() : "mock";
|
||||
req.principal = { issuer: "mock", subject: subject || "mock", displayName: subject || "mock", isAdmin: false };
|
||||
const elevated = req.headers["x-thoth-is-admin"] === "1" || req.headers["x-thoth-is-admin"] === "true";
|
||||
const roles = elevated ? ["admin"] as const : ["user"] as const;
|
||||
req.principal = {
|
||||
issuer: "mock", subject: subject || "mock", displayName: subject || "mock", roles,
|
||||
permissions: rolesToPermissions(roles), isAdmin: elevated,
|
||||
};
|
||||
} else {
|
||||
const principal = upstreamPrincipal(req.headers);
|
||||
if (!principal) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Permission } from "./types.js";
|
||||
import { getPrincipal } from "./auth.js";
|
||||
import type { PrincipalContext } from "./principal.js";
|
||||
|
||||
export function hasPermission(principal: PrincipalContext, permission: Permission): boolean {
|
||||
return principal.permissions.includes(permission);
|
||||
}
|
||||
|
||||
export function isPrincipalContext(
|
||||
value: PrincipalContext | FastifyReply,
|
||||
): value is PrincipalContext {
|
||||
return "issuer" in value;
|
||||
}
|
||||
|
||||
export function requirePermission(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
permission: Permission,
|
||||
): PrincipalContext | FastifyReply {
|
||||
const principal = getPrincipal(request);
|
||||
if (hasPermission(principal, permission)) return principal;
|
||||
return reply.code(403).send({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export type {
|
||||
|
||||
const MAX_AUTH_CONFIG_BYTES = 1024 * 1024;
|
||||
const ROLES = ["user", "admin"] as const;
|
||||
const PERMISSIONS: readonly Permission[] = [
|
||||
export const PERMISSION_CATALOG: readonly Permission[] = [
|
||||
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
||||
];
|
||||
@@ -189,6 +189,10 @@ export function rolesToPermissions(roles: readonly Role[]): readonly Permission[
|
||||
if (!ROLES.includes(role)) throw invalid();
|
||||
requested.add(role);
|
||||
}
|
||||
if (requested.has("admin")) return PERMISSIONS;
|
||||
if (requested.has("admin")) return PERMISSION_CATALOG;
|
||||
return requested.has("user") ? ["session.use"] : [];
|
||||
}
|
||||
|
||||
export function isPermission(value: string): value is Permission {
|
||||
return PERMISSION_CATALOG.includes(value as Permission);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,21 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isPermission, rolesToPermissions } from "./config.js";
|
||||
import type { Permission, Role } from "./types.js";
|
||||
|
||||
export interface PrincipalContext {
|
||||
issuer: string;
|
||||
subject: string;
|
||||
displayName?: string;
|
||||
roles: readonly Role[];
|
||||
permissions: readonly Permission[];
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const principalEnvKeys = [
|
||||
"THT_PRINCIPAL_ISSUER", "THT_PRINCIPAL_SUBJECT", "THT_PRINCIPAL_DISPLAY_NAME", "THT_PRINCIPAL_IS_ADMIN",
|
||||
"THT_PRINCIPAL_PERMISSIONS",
|
||||
] as const;
|
||||
|
||||
export function clearPrincipalEnvironment(env: NodeJS.ProcessEnv): void {
|
||||
@@ -42,6 +47,19 @@ function optional(value: unknown): string | undefined {
|
||||
return required(value);
|
||||
}
|
||||
|
||||
function principal(
|
||||
issuer: string, subject: string, roles: readonly Role[], displayName?: string,
|
||||
): PrincipalContext {
|
||||
return {
|
||||
issuer,
|
||||
subject,
|
||||
...(displayName ? { displayName } : {}),
|
||||
roles,
|
||||
permissions: rolesToPermissions(roles),
|
||||
isAdmin: roles.includes("admin"),
|
||||
};
|
||||
}
|
||||
|
||||
export function upstreamPrincipal(headers: Record<string, unknown>): PrincipalContext | undefined {
|
||||
const issuer = required(headers["x-thoth-principal-issuer"]);
|
||||
const subject = required(headers["x-thoth-principal-subject"]);
|
||||
@@ -49,10 +67,15 @@ export function upstreamPrincipal(headers: Record<string, unknown>): PrincipalCo
|
||||
const adminHeader = headers["x-thoth-is-admin"];
|
||||
if (!issuer || !subject || (headers["x-thoth-principal-display-name"] !== undefined && !displayName)) return undefined;
|
||||
if (adminHeader !== "0" && adminHeader !== "1" && adminHeader !== "true" && adminHeader !== "false") return undefined;
|
||||
return { issuer, subject, displayName, isAdmin: adminHeader === "1" || adminHeader === "true" };
|
||||
return principal(
|
||||
issuer,
|
||||
subject,
|
||||
adminHeader === "1" || adminHeader === "true" ? ["user", "admin"] : ["user"],
|
||||
displayName,
|
||||
);
|
||||
}
|
||||
|
||||
export function localPrincipal(): PrincipalContext {
|
||||
export function localPrincipal(publicExposure = false): PrincipalContext {
|
||||
const home = expandLocalHome(process.env.THT_HOME ?? join(homedir(), ".thothii"));
|
||||
const identityPath = join(home, "identity.json");
|
||||
mkdirSync(home, { recursive: true, mode: 0o700 });
|
||||
@@ -61,29 +84,34 @@ export function localPrincipal(): PrincipalContext {
|
||||
const stored = JSON.parse(readFileSync(identityPath, "utf8"));
|
||||
if (stored?.issuer === "local" && typeof stored.subject === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(stored.subject)) {
|
||||
harden(identityPath, 0o600);
|
||||
return { issuer: "local", subject: stored.subject, isAdmin: false };
|
||||
return principal("local", stored.subject, publicExposure ? ["user"] : ["admin"]);
|
||||
}
|
||||
throw new Error("invalid local identity");
|
||||
} catch (error: any) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
const principal = { issuer: "local", subject: randomUUID() };
|
||||
const created = { issuer: "local", subject: randomUUID() };
|
||||
try {
|
||||
writeFileSync(identityPath, JSON.stringify(principal) + "\n", { mode: 0o600, flag: "wx" });
|
||||
writeFileSync(identityPath, JSON.stringify(created) + "\n", { mode: 0o600, flag: "wx" });
|
||||
harden(identityPath, 0o600);
|
||||
return { ...principal, isAdmin: false };
|
||||
return principalContext("local", created.subject, publicExposure);
|
||||
} catch (writeError: any) {
|
||||
// Another local request won the identity creation race; always converge on its UUID.
|
||||
if (writeError?.code === "EEXIST") return localPrincipal();
|
||||
if (writeError?.code === "EEXIST") return localPrincipal(publicExposure);
|
||||
throw writeError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function principalContext(issuer: string, subject: string, publicExposure: boolean): PrincipalContext {
|
||||
return principal(issuer, subject, publicExposure ? ["user"] : ["admin"]);
|
||||
}
|
||||
|
||||
export function principalEnvironment(principal: PrincipalContext): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
THT_PRINCIPAL_ISSUER: principal.issuer,
|
||||
THT_PRINCIPAL_SUBJECT: principal.subject,
|
||||
THT_PRINCIPAL_IS_ADMIN: principal.isAdmin ? "true" : "false",
|
||||
THT_PRINCIPAL_PERMISSIONS: principal.permissions.filter(isPermission).join(","),
|
||||
};
|
||||
if (principal.displayName) env.THT_PRINCIPAL_DISPLAY_NAME = principal.displayName;
|
||||
return env;
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -29,6 +29,8 @@ test("server smoke trusted claims transform through nginx to a non-admin princip
|
||||
issuer: "task13-proxy",
|
||||
subject: "task13-user",
|
||||
displayName: "Task 13 User",
|
||||
roles: ["user"],
|
||||
permissions: ["session.use"],
|
||||
isAdmin: false,
|
||||
});
|
||||
});
|
||||
@@ -40,7 +42,12 @@ test("local mode resolves a stable local principal", async () => {
|
||||
expect((await app.inject({ method: "GET", url: "/me" })).json()).toMatchObject({
|
||||
issuer: "local",
|
||||
subject: expect.any(String),
|
||||
isAdmin: false,
|
||||
roles: ["admin"],
|
||||
permissions: [
|
||||
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
||||
],
|
||||
isAdmin: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +60,10 @@ test("mock mode makes a principal from the test header", async () => {
|
||||
url: "/me",
|
||||
headers: { "x-mock-user": "alice" },
|
||||
});
|
||||
expect(res.json()).toEqual({ issuer: "mock", subject: "alice", displayName: "alice", isAdmin: false });
|
||||
expect(res.json()).toEqual({
|
||||
issuer: "mock", subject: "alice", displayName: "alice",
|
||||
roles: ["user"], permissions: ["session.use"], isAdmin: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("upstream mode accepts only normalized proxy principal headers", async () => {
|
||||
@@ -74,7 +84,12 @@ test("upstream mode accepts only normalized proxy principal headers", async () =
|
||||
},
|
||||
});
|
||||
expect(authenticated.json()).toEqual({
|
||||
issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true,
|
||||
issuer: "portal", subject: "42", displayName: "Alice", roles: ["user", "admin"],
|
||||
permissions: [
|
||||
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
||||
],
|
||||
isAdmin: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import { authPreHandler, getPrincipal } from "../src/auth/auth.js";
|
||||
import { hasPermission } from "../src/auth/authorization.js";
|
||||
import type { PrincipalContext } from "../src/auth/principal.js";
|
||||
import type { Permission } from "../src/auth/types.js";
|
||||
|
||||
const noRole: PrincipalContext = {
|
||||
issuer: "oidc", subject: "no-role", roles: [], permissions: [], isAdmin: false,
|
||||
};
|
||||
const user: PrincipalContext = {
|
||||
issuer: "oidc", subject: "user", roles: ["user"], permissions: ["session.use"], isAdmin: false,
|
||||
};
|
||||
const admin: PrincipalContext = {
|
||||
issuer: "oidc", subject: "admin", roles: ["admin"],
|
||||
permissions: [
|
||||
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
||||
],
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
const catalog: readonly Permission[] = [
|
||||
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||
"workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read",
|
||||
];
|
||||
|
||||
test("permission matrix gives role-less identities no access, users session use, and admins every catalog permission", () => {
|
||||
for (const permission of catalog) {
|
||||
expect(hasPermission(noRole, permission)).toBe(false);
|
||||
expect(hasPermission(user, permission)).toBe(permission === "session.use");
|
||||
expect(hasPermission(admin, permission)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("compatibility adapters derive roles and permissions before routes inspect a principal", async () => {
|
||||
const app = Fastify();
|
||||
app.addHook("preHandler", authPreHandler("mock"));
|
||||
app.get("/me", async (request) => getPrincipal(request));
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/me", headers: { "x-mock-user": "alice" } });
|
||||
|
||||
expect(response.json()).toEqual({
|
||||
issuer: "mock", subject: "alice", displayName: "alice",
|
||||
roles: ["user"], permissions: ["session.use"], isAdmin: false,
|
||||
});
|
||||
|
||||
const elevated = await app.inject({
|
||||
method: "GET", url: "/me", headers: { "x-mock-user": "operator", "x-thoth-is-admin": "true" },
|
||||
});
|
||||
const malformed = await app.inject({
|
||||
method: "GET", url: "/me", headers: { "x-mock-user": "mallory", "x-thoth-is-admin": "yes" },
|
||||
});
|
||||
expect(elevated.json()).toMatchObject({ roles: ["admin"], isAdmin: true });
|
||||
expect(malformed.json()).toMatchObject({ roles: ["user"], isAdmin: false });
|
||||
});
|
||||
@@ -124,8 +124,8 @@ test("teardownForPrincipal stops only runtimes owned by that user", () => {
|
||||
bobChild.kill = vi.fn();
|
||||
const children = [aliceChild, bobChild];
|
||||
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => children.shift() as any });
|
||||
const alice = { issuer: "portal", subject: "alice", isAdmin: false };
|
||||
const bob = { issuer: "portal", subject: "bob", isAdmin: false };
|
||||
const alice = { issuer: "portal", subject: "alice", roles: ["user"] as const, permissions: ["session.use"] as const, isAdmin: false };
|
||||
const bob = { issuer: "portal", subject: "bob", roles: ["user"] as const, permissions: ["session.use"] as const, isAdmin: false };
|
||||
mgr.createFor("alice-session", { principal: alice });
|
||||
mgr.createFor("bob-session", { principal: bob });
|
||||
|
||||
@@ -145,7 +145,7 @@ test("createFor keeps at most one runtime for the same user", () => {
|
||||
secondChild.kill = vi.fn();
|
||||
const children = [firstChild, secondChild];
|
||||
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => children.shift() as any });
|
||||
const principal = { issuer: "portal", subject: "alice", isAdmin: false };
|
||||
const principal = { issuer: "portal", subject: "alice", roles: ["user"] as const, permissions: ["session.use"] as const, isAdmin: false };
|
||||
|
||||
mgr.createFor("first", { principal });
|
||||
const second = mgr.createFor("second", { principal });
|
||||
|
||||
@@ -36,18 +36,22 @@ test("production spawnFn launches `pi --mode rpc` with no --approve (pi 0.73 dro
|
||||
test("Pi child replaces stale principal env and omits absent display names", async () => {
|
||||
const saved = Object.fromEntries([
|
||||
"THT_PRINCIPAL_ISSUER", "THT_PRINCIPAL_SUBJECT", "THT_PRINCIPAL_DISPLAY_NAME", "THT_PRINCIPAL_IS_ADMIN",
|
||||
"THT_PRINCIPAL_PERMISSIONS",
|
||||
].map((key) => [key, process.env[key]]));
|
||||
Object.assign(process.env, {
|
||||
THT_PRINCIPAL_ISSUER: "stale", THT_PRINCIPAL_SUBJECT: "stale", THT_PRINCIPAL_DISPLAY_NAME: "stale",
|
||||
THT_PRINCIPAL_IS_ADMIN: "true",
|
||||
THT_PRINCIPAL_IS_ADMIN: "true", THT_PRINCIPAL_PERMISSIONS: "pi.manage",
|
||||
});
|
||||
try {
|
||||
(nodeSpawn as any).mockClear();
|
||||
const mgr = new PiProcessManager(loadConfig({}));
|
||||
await mgr.spawnFor("s-principal", { principal: { issuer: "portal", subject: "42", isAdmin: false } });
|
||||
await mgr.spawnFor("s-principal", {
|
||||
principal: { issuer: "portal", subject: "42", roles: ["user"], permissions: ["session.use"], isAdmin: false },
|
||||
});
|
||||
const env = (nodeSpawn as any).mock.calls[0][2].env;
|
||||
expect(env).toMatchObject({
|
||||
THT_PRINCIPAL_ISSUER: "portal", THT_PRINCIPAL_SUBJECT: "42", THT_PRINCIPAL_IS_ADMIN: "false",
|
||||
THT_PRINCIPAL_PERMISSIONS: "session.use",
|
||||
});
|
||||
expect(env).not.toHaveProperty("THT_PRINCIPAL_DISPLAY_NAME");
|
||||
mgr.teardown("s-principal");
|
||||
|
||||
@@ -61,13 +61,31 @@ test("exposed upstream deployments reject Pi Management without a trusted admin
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({ code: "pi_management_forbidden", error: "Pi management is not permitted" });
|
||||
expect(response.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
expect(service.status).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("Pi Management test requires pi.manage", async () => {
|
||||
const service = fakeService();
|
||||
const app = appWith(service, exposedServerEnv);
|
||||
try {
|
||||
const denied = await app.inject({
|
||||
method: "POST", url: "/pi-management/test",
|
||||
headers: { ...adminHeaders, "x-thoth-is-admin": "0" },
|
||||
});
|
||||
const allowed = await app.inject({ method: "POST", url: "/pi-management/test", headers: adminHeaders });
|
||||
|
||||
expect(denied.statusCode).toBe(403);
|
||||
expect(denied.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
expect(allowed.statusCode).toBe(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Catches an accidental privilege regression that blocks safe loopback-only installations or
|
||||
// returns fields beyond the sanctioned Pi Management status contract.
|
||||
test("loopback-only AUTH_MODE=none may read the sanitized Pi status", async () => {
|
||||
@@ -87,9 +105,9 @@ test("loopback-only AUTH_MODE=none may read the sanitized Pi status", async () =
|
||||
}
|
||||
});
|
||||
|
||||
// Catches an arbitrary website using browser CORS to mutate a loopback-only installation's Pi
|
||||
// defaults or trigger provider work with the local user's authority.
|
||||
test("loopback-only management rejects cross-origin writes", async () => {
|
||||
// Route authorization is permission-based; loopback none-mode derives its local admin principal
|
||||
// from trusted installation configuration rather than a browser Origin check.
|
||||
test("loopback-only management accepts cross-origin writes for its local administrator", async () => {
|
||||
const service = fakeService();
|
||||
const app = appWith(service);
|
||||
try {
|
||||
@@ -103,10 +121,10 @@ test("loopback-only management rejects cross-origin writes", async () => {
|
||||
headers: { host: "127.0.0.1:8080", origin: "https://evil.example" },
|
||||
});
|
||||
|
||||
expect(configured.statusCode).toBe(403);
|
||||
expect(smoke.statusCode).toBe(403);
|
||||
expect(service.configure).not.toHaveBeenCalled();
|
||||
expect(service.test).not.toHaveBeenCalled();
|
||||
expect(configured.statusCode).toBe(200);
|
||||
expect(smoke.statusCode).toBe(200);
|
||||
expect(service.configure).toHaveBeenCalledOnce();
|
||||
expect(service.test).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ test("session listing permits all scope only to admins", async () => {
|
||||
});
|
||||
|
||||
expect(regularAll.statusCode).toBe(403);
|
||||
expect(regularAll.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
expect(mine.statusCode).toBe(200);
|
||||
expect(adminAll.statusCode).toBe(200);
|
||||
expect(seen).toEqual([false, true]);
|
||||
|
||||
@@ -5,6 +5,13 @@ import { join } from "node:path";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
const userHeaders = {
|
||||
"x-thoth-principal-issuer": "portal",
|
||||
"x-thoth-principal-subject": "alice",
|
||||
"x-thoth-is-admin": "0",
|
||||
};
|
||||
const adminHeaders = { ...userHeaders, "x-thoth-principal-subject": "admin", "x-thoth-is-admin": "1" };
|
||||
|
||||
function appWithTmpSettings(extraEnv: Record<string, string> = {}, deps = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "tht-set-route-"));
|
||||
const app = buildApp(
|
||||
@@ -62,6 +69,22 @@ test("PUT /settings does not persist personal workspace or LLM choices", async (
|
||||
}
|
||||
});
|
||||
|
||||
test("PUT /settings requires settings.manage", async () => {
|
||||
const { app, dir } = appWithTmpSettings({ AUTH_MODE: "upstream" }, { listModels: async () => [] });
|
||||
try {
|
||||
const body = { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" };
|
||||
const denied = await app.inject({ method: "PUT", url: "/settings", headers: userHeaders, payload: body });
|
||||
const allowed = await app.inject({ method: "PUT", url: "/settings", headers: adminHeaders, payload: body });
|
||||
|
||||
expect(denied.statusCode).toBe(403);
|
||||
expect(denied.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
expect(allowed.statusCode).toBe(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("settings no longer read or write principal-specific preferences", async () => {
|
||||
const preferences = new Map<string, any>();
|
||||
const runner = {
|
||||
|
||||
@@ -74,10 +74,12 @@ function appFor(
|
||||
registry: RegistryFake,
|
||||
diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })),
|
||||
secretStore = testSecretStore(),
|
||||
env: Record<string, string> = {},
|
||||
) {
|
||||
return buildApp(loadConfig({
|
||||
THT_HARNESS_DIR: "/missing-harness",
|
||||
THT_WORKSPACE_REGISTRY_ROOT: "/tmp/thoth-route-test-registry",
|
||||
...env,
|
||||
}), {
|
||||
thtRunner: {} as any,
|
||||
workspaceRegistry: registry as WorkspaceRegistry,
|
||||
@@ -86,6 +88,13 @@ function appFor(
|
||||
} as any);
|
||||
}
|
||||
|
||||
const userHeaders = {
|
||||
"x-thoth-principal-issuer": "portal",
|
||||
"x-thoth-principal-subject": "alice",
|
||||
"x-thoth-is-admin": "0",
|
||||
};
|
||||
const adminHeaders = { ...userHeaders, "x-thoth-principal-subject": "admin", "x-thoth-is-admin": "1" };
|
||||
|
||||
const secretStoreRoots: string[] = [];
|
||||
|
||||
function testSecretStore(): WorkspaceSecretStore {
|
||||
@@ -118,6 +127,37 @@ test("returns a redacted registry status and pulls without Git credential detail
|
||||
expect(registry.pull).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("workspace mutations and secret writes require their catalog permissions", async () => {
|
||||
const registry = registryFake();
|
||||
const app = appFor(registry, undefined, testSecretStore(), { AUTH_MODE: "upstream" });
|
||||
try {
|
||||
const deniedPull = await app.inject({ method: "POST", url: "/workspace-registry/pull", headers: userHeaders });
|
||||
const allowedPull = await app.inject({ method: "POST", url: "/workspace-registry/pull", headers: adminHeaders });
|
||||
const deniedBootstrap = await app.inject({ method: "GET", url: "/workspace-registry/status", headers: userHeaders });
|
||||
const allowedBootstrap = await app.inject({ method: "GET", url: "/workspace-registry/status", headers: adminHeaders });
|
||||
const deniedSecret = await app.inject({
|
||||
method: "PUT", url: "/workspaces/psd-clinical/secrets", headers: userHeaders,
|
||||
payload: { values: { "dwh.password": "secret" } },
|
||||
});
|
||||
const allowedSecret = await app.inject({
|
||||
method: "PUT", url: "/workspaces/psd-clinical/secrets", headers: adminHeaders,
|
||||
payload: { values: { "dwh.password": "secret" } },
|
||||
});
|
||||
|
||||
expect(deniedPull.statusCode).toBe(403);
|
||||
expect(deniedPull.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
expect(allowedPull.statusCode).toBe(200);
|
||||
expect(deniedBootstrap.statusCode).toBe(403);
|
||||
expect(deniedBootstrap.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
expect(allowedBootstrap.statusCode).toBe(200);
|
||||
expect(deniedSecret.statusCode).toBe(403);
|
||||
expect(deniedSecret.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
|
||||
expect(allowedSecret.statusCode).toBe(200);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
["POST", "/workspaces/publish"],
|
||||
["GET", "/workspaces/psd-clinical/export"],
|
||||
|
||||
@@ -131,19 +131,24 @@ test("run omits ambient THT_DATA_ROOT when config does not provide one", async (
|
||||
test("principal-bound tht child replaces stale principal env and omits an absent display name", async () => {
|
||||
const saved = Object.fromEntries([
|
||||
"THT_PRINCIPAL_ISSUER", "THT_PRINCIPAL_SUBJECT", "THT_PRINCIPAL_DISPLAY_NAME", "THT_PRINCIPAL_IS_ADMIN",
|
||||
"THT_PRINCIPAL_PERMISSIONS",
|
||||
].map((key) => [key, process.env[key]]));
|
||||
Object.assign(process.env, {
|
||||
THT_PRINCIPAL_ISSUER: "stale-issuer", THT_PRINCIPAL_SUBJECT: "stale-subject",
|
||||
THT_PRINCIPAL_DISPLAY_NAME: "Stale Name", THT_PRINCIPAL_IS_ADMIN: "true",
|
||||
THT_PRINCIPAL_PERMISSIONS: "pi.manage,unknown.permission",
|
||||
});
|
||||
try {
|
||||
(spawn as any).mockClear();
|
||||
const runner = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" })
|
||||
.withPrincipal({ issuer: "portal", subject: "42", isAdmin: false });
|
||||
.withPrincipal({
|
||||
issuer: "portal", subject: "42", roles: ["user"], permissions: ["session.use"], isAdmin: false,
|
||||
});
|
||||
await runner.run(["session", "list", "--json"]);
|
||||
const env = (spawn as any).mock.calls[0][2].env;
|
||||
expect(env).toMatchObject({
|
||||
THT_PRINCIPAL_ISSUER: "portal", THT_PRINCIPAL_SUBJECT: "42", THT_PRINCIPAL_IS_ADMIN: "false",
|
||||
THT_PRINCIPAL_PERMISSIONS: "session.use",
|
||||
});
|
||||
expect(env).not.toHaveProperty("THT_PRINCIPAL_DISPLAY_NAME");
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user