From 2e0489ce22bd1f626939148b0f552056bffad039 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 16 Aug 2026 17:52:03 +0200 Subject: [PATCH] feat(auth): centralize ThothII permission enforcement --- backend/src/app.ts | 8 ++-- backend/src/auth/auth.ts | 12 +++-- backend/src/auth/authorization.ts | 24 ++++++++++ backend/src/auth/config.ts | 8 +++- backend/src/auth/principal.ts | 42 ++++++++++++++--- backend/src/routes/meta.ts | 4 +- backend/src/routes/pi-management.ts | 39 ++-------------- backend/src/routes/sessions.ts | 54 +++++++++++++++------- backend/src/routes/settings.ts | 10 ++-- backend/src/routes/sql.ts | 10 ++-- backend/src/routes/workspaces.ts | 16 +++++-- backend/test/auth.test.ts | 21 +++++++-- backend/test/authorization.test.ts | 56 +++++++++++++++++++++++ backend/test/pi-process-manager.test.ts | 6 +-- backend/test/pi-spawn-args.test.ts | 8 +++- backend/test/routes-pi-management.test.ts | 34 ++++++++++---- backend/test/routes-sessions.test.ts | 1 + backend/test/routes-settings.test.ts | 23 ++++++++++ backend/test/routes-workspaces.test.ts | 40 ++++++++++++++++ backend/test/tht-runner.test.ts | 7 ++- 20 files changed, 330 insertions(+), 93 deletions(-) create mode 100644 backend/src/auth/authorization.ts create mode 100644 backend/test/authorization.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 1b92762f..56e74009 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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; } diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index 8379e8ba..8b4ad718 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -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) { diff --git a/backend/src/auth/authorization.ts b/backend/src/auth/authorization.ts new file mode 100644 index 00000000..bef58c8b --- /dev/null +++ b/backend/src/auth/authorization.ts @@ -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" }); +} diff --git a/backend/src/auth/config.ts b/backend/src/auth/config.ts index 32acc4c3..d3aa3074 100644 --- a/backend/src/auth/config.ts +++ b/backend/src/auth/config.ts @@ -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); +} diff --git a/backend/src/auth/principal.ts b/backend/src/auth/principal.ts index a58aff1f..95302a56 100644 --- a/backend/src/auth/principal.ts +++ b/backend/src/auth/principal.ts @@ -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): 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): 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; diff --git a/backend/src/routes/meta.ts b/backend/src/routes/meta.ts index 94572160..018536a3 100644 --- a/backend/src/routes/meta.ts +++ b/backend/src/routes/meta.ts @@ -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; @@ -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() }; diff --git a/backend/src/routes/pi-management.ts b/backend/src/routes/pi-management.ts index c1a26a3a..1021bd4c 100644 --- a/backend/src/routes/pi-management.ts +++ b/backend/src/routes/pi-management.ts @@ -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( request: FastifyRequest, reply: FastifyReply, - deps: { config: AppConfig; service: PiManagementService }, + deps: { service: PiManagementService }, action: () => Promise, ): Promise { - 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( 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; - } -} diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 4830c2c6..f2596eca 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -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 => { - const runner = runnerFor(principal); + const locateSession = async ( + principal: PrincipalContext, id: string, permission: "session.read_all" | "session.manage_all" = "session.read_all", + ): Promise => { + 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 => { @@ -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 => - await locateSession(principal, id); + const authorize = async ( + principal: PrincipalContext, id: string, permission: "session.read_all" | "session.manage_all" = "session.read_all", + ): Promise => 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).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); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 8127b657..71afd333 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -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" }); } diff --git a/backend/src/routes/sql.ts b/backend/src/routes/sql.ts index a5e48554..c9a13517 100644 --- a/backend/src/routes/sql.ts +++ b/backend/src/routes/sql.ts @@ -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; @@ -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" }); diff --git a/backend/src/routes/workspaces.ts b/backend/src/routes/workspaces.ts index 721f214c..73e7c35e 100644 --- a/backend/src/routes/workspaces.ts +++ b/backend/src/routes/workspaces.ts @@ -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); diff --git a/backend/test/auth.test.ts b/backend/test/auth.test.ts index 24eec111..0bec5744 100644 --- a/backend/test/auth.test.ts +++ b/backend/test/auth.test.ts @@ -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, }); }); diff --git a/backend/test/authorization.test.ts b/backend/test/authorization.test.ts new file mode 100644 index 00000000..57281987 --- /dev/null +++ b/backend/test/authorization.test.ts @@ -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 }); +}); diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index 47496042..47fc5104 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -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 }); diff --git a/backend/test/pi-spawn-args.test.ts b/backend/test/pi-spawn-args.test.ts index 45ed3621..15e3c4c6 100644 --- a/backend/test/pi-spawn-args.test.ts +++ b/backend/test/pi-spawn-args.test.ts @@ -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"); diff --git a/backend/test/routes-pi-management.test.ts b/backend/test/routes-pi-management.test.ts index 9b4902e2..5a2234e8 100644 --- a/backend/test/routes-pi-management.test.ts +++ b/backend/test/routes-pi-management.test.ts @@ -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(); } diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 38e865ba..8d36693e 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -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]); diff --git a/backend/test/routes-settings.test.ts b/backend/test/routes-settings.test.ts index f15f4e6a..9a10d7b0 100644 --- a/backend/test/routes-settings.test.ts +++ b/backend/test/routes-settings.test.ts @@ -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 = {}, 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(); const runner = { diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index 9e7b824a..7f2afa10 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -74,10 +74,12 @@ function appFor( registry: RegistryFake, diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })), secretStore = testSecretStore(), + env: Record = {}, ) { 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"], diff --git a/backend/test/tht-runner.test.ts b/backend/test/tht-runner.test.ts index 848743b9..e5c9f1ec 100644 --- a/backend/test/tht-runner.test.ts +++ b/backend/test/tht-runner.test.ts @@ -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 {