91 lines
3.6 KiB
TypeScript
91 lines
3.6 KiB
TypeScript
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
export interface PrincipalContext {
|
|
issuer: string;
|
|
subject: string;
|
|
displayName?: string;
|
|
isAdmin: boolean;
|
|
}
|
|
|
|
const principalEnvKeys = [
|
|
"THT_PRINCIPAL_ISSUER", "THT_PRINCIPAL_SUBJECT", "THT_PRINCIPAL_DISPLAY_NAME", "THT_PRINCIPAL_IS_ADMIN",
|
|
] as const;
|
|
|
|
export function clearPrincipalEnvironment(env: NodeJS.ProcessEnv): void {
|
|
for (const key of principalEnvKeys) delete env[key];
|
|
}
|
|
|
|
export function expandLocalHome(path: string, home = homedir()): string {
|
|
if (path === "~") return home;
|
|
if (path.startsWith("~/")) return join(home, path.slice(2));
|
|
return path;
|
|
}
|
|
|
|
function harden(path: string, mode: number): void {
|
|
if (process.platform === "win32") return;
|
|
try { chmodSync(path, mode); } catch { /* best-effort parity with harness local storage */ }
|
|
}
|
|
|
|
const invalid = (value: string) => value.length === 0 || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value);
|
|
|
|
function required(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const normalized = value.trim();
|
|
return invalid(normalized) ? undefined : normalized;
|
|
}
|
|
|
|
function optional(value: unknown): string | undefined {
|
|
if (value === undefined) return undefined;
|
|
return required(value);
|
|
}
|
|
|
|
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"]);
|
|
const displayName = optional(headers["x-thoth-principal-display-name"]);
|
|
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" };
|
|
}
|
|
|
|
export function localPrincipal(): PrincipalContext {
|
|
const home = expandLocalHome(process.env.THT_HOME ?? join(homedir(), ".thothii"));
|
|
const identityPath = join(home, "identity.json");
|
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
harden(home, 0o700);
|
|
try {
|
|
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 };
|
|
}
|
|
throw new Error("invalid local identity");
|
|
} catch (error: any) {
|
|
if (error?.code !== "ENOENT") throw error;
|
|
const principal = { issuer: "local", subject: randomUUID() };
|
|
try {
|
|
writeFileSync(identityPath, JSON.stringify(principal) + "\n", { mode: 0o600, flag: "wx" });
|
|
harden(identityPath, 0o600);
|
|
return { ...principal, isAdmin: false };
|
|
} catch (writeError: any) {
|
|
// Another local request won the identity creation race; always converge on its UUID.
|
|
if (writeError?.code === "EEXIST") return localPrincipal();
|
|
throw writeError;
|
|
}
|
|
}
|
|
}
|
|
|
|
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",
|
|
};
|
|
if (principal.displayName) env.THT_PRINCIPAL_DISPLAY_NAME = principal.displayName;
|
|
return env;
|
|
}
|