feat(auth): centralize ThothII permission enforcement

This commit is contained in:
2026-08-16 17:52:03 +02:00
parent 23ac75ce1d
commit 2e0489ce22
20 changed files with 330 additions and 93 deletions
+9 -3
View File
@@ -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) {
+24
View File
@@ -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" });
}
+6 -2
View File
@@ -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);
}
+35 -7
View File
@@ -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;