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