feat(auth): integrate authentication with installation lifecycle
This commit is contained in:
@@ -82,12 +82,6 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
|
||||
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
|
||||
if (isPublicRoute(request)) return;
|
||||
|
||||
const operator = loopbackMaintenancePrincipal(request);
|
||||
if (operator) {
|
||||
request.principal = operator;
|
||||
return;
|
||||
}
|
||||
|
||||
if (legacy) {
|
||||
await legacy(request, reply);
|
||||
if (reply.sent || !STATE_CHANGING_METHODS.has(request.method)) return;
|
||||
@@ -144,15 +138,6 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
|
||||
};
|
||||
}
|
||||
|
||||
function loopbackMaintenancePrincipal(request: FastifyRequest): PrincipalContext | undefined {
|
||||
if (request.ip !== "127.0.0.1" && request.ip !== "::1" && request.ip !== "::ffff:127.0.0.1") return undefined;
|
||||
if (singleHeader(request.headers["x-thoth-principal-issuer"]) !== "tht"
|
||||
|| singleHeader(request.headers["x-thoth-principal-subject"]) !== "tht-maintenance"
|
||||
|| singleHeader(request.headers["x-thoth-principal-display-name"]) !== "Tht maintenance"
|
||||
|| singleHeader(request.headers["x-thoth-is-admin"]) !== "1") return undefined;
|
||||
return upstreamPrincipal(request.headers);
|
||||
}
|
||||
|
||||
export function requireCsrf(request: FastifyRequest, reply: FastifyReply): true | FastifyReply {
|
||||
const expectedOrigin = request.authPublicOrigin;
|
||||
const token = request.authSessionToken;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Installation-scoped host operations that must not impersonate an HTTP administrator.
|
||||
* This command runs only through `docker compose exec core`; it never accepts credentials,
|
||||
* headers, paths, or arbitrary code from the caller.
|
||||
*/
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import { loadConfig, type AppConfig } from "./config.js";
|
||||
import { rolesToPermissions } from "./auth/config.js";
|
||||
import type { PrincipalContext } from "./auth/principal.js";
|
||||
import { createPiModelLister } from "./pi/list-models.js";
|
||||
import { createPiManagement } from "./pi/management.js";
|
||||
import { effectiveSettings } from "./routes/settings.js";
|
||||
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
|
||||
import { loadSettings } from "./settings/settings-store.js";
|
||||
import { ThtRunner, type SessionRow } from "./tht/tht-runner.js";
|
||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||
import { WorkspaceSecretStore } from "./workspaces/secret-store.js";
|
||||
|
||||
type OperatorAction = "maintenance-activate" | "maintenance-deactivate" | "maintenance-status"
|
||||
| "session-inventory" | "workflow-doctor" | "pi-options" | "pi-test" | "effective-settings";
|
||||
|
||||
const lifecyclePrincipal: PrincipalContext = {
|
||||
issuer: "tht-operator-command",
|
||||
subject: "installation-lifecycle",
|
||||
displayName: "Installation lifecycle",
|
||||
roles: ["admin"],
|
||||
permissions: rolesToPermissions(["admin"]),
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
function operatorRunner(config: AppConfig): ThtRunner {
|
||||
const workspaceSecretStore = new WorkspaceSecretStore({
|
||||
root: config.workspaceSecretStoreRoot,
|
||||
runtimeRoot: config.workspaceSecretRuntimeRoot,
|
||||
installationId: config.workspaceRegistry.installationId,
|
||||
});
|
||||
return new ThtRunner({
|
||||
thtBin: config.thtBin,
|
||||
harnessDir: config.harnessDir,
|
||||
configPath: process.env.THT_CONFIG ?? "config/tht.yaml",
|
||||
dataRoot: config.dataRoot,
|
||||
runtimeSnapshotRoot: join(config.workspaceRegistry.root, "snapshots", "runtime"),
|
||||
secretRoots: config.workspaceRegistry.secretRoots,
|
||||
secretsFile: config.secretsFile,
|
||||
secretFiles: config.secretFiles,
|
||||
workspaceSecretStore,
|
||||
semanticRuntime: {
|
||||
internalQdrantUrl: config.internalQdrantUrl,
|
||||
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||
internalEmbeddingModel: config.internalEmbeddingModel,
|
||||
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
||||
},
|
||||
}).withPrincipal(lifecyclePrincipal);
|
||||
}
|
||||
|
||||
async function sessionInventory(config: AppConfig): Promise<Array<Pick<SessionRow, "status" | "archived">>> {
|
||||
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
||||
const revisions = await registry.listRetainedSnapshots();
|
||||
const runner = operatorRunner(config);
|
||||
const sessions = new Map<string, SessionRow>();
|
||||
for (const revision of revisions) {
|
||||
for (const session of await runner.sessionList(revision.snapshotPath)) sessions.set(session.id, session);
|
||||
}
|
||||
return [...sessions.values()].map(({ status, archived }) => ({ status, archived: archived === true }));
|
||||
}
|
||||
|
||||
async function workflowDiagnostics(config: AppConfig): Promise<{ ready: true; workspaces: number }> {
|
||||
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
||||
const revisions = await registry.listRetainedSnapshots();
|
||||
if (revisions.length === 0) throw new Error("workflow diagnostics unavailable");
|
||||
const runner = operatorRunner(config);
|
||||
for (const revision of revisions) {
|
||||
const result = await runner.run(["doctor", "--json"], revision.snapshotPath);
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
throw new Error("workflow diagnostics failed");
|
||||
}
|
||||
if (
|
||||
result.code !== 0 || !payload || typeof payload !== "object"
|
||||
|| (payload as { ok?: unknown }).ok !== true
|
||||
) throw new Error("workflow diagnostics failed");
|
||||
}
|
||||
return { ready: true, workspaces: revisions.length };
|
||||
}
|
||||
|
||||
export async function runOperatorAction(
|
||||
action: OperatorAction,
|
||||
config: AppConfig,
|
||||
): Promise<unknown> {
|
||||
if (action.startsWith("maintenance-")) {
|
||||
const barrier = new MaintenanceBarrier(config.maintenanceFile);
|
||||
if (action === "maintenance-activate") await barrier.activate();
|
||||
if (action === "maintenance-deactivate") barrier.deactivate();
|
||||
return barrier.status();
|
||||
}
|
||||
if (action === "session-inventory") return await sessionInventory(config);
|
||||
if (action === "workflow-doctor") return await workflowDiagnostics(config);
|
||||
if (action === "effective-settings") return effectiveSettings(config, loadSettings(config));
|
||||
const service = createPiManagement(config, { listModels: createPiModelLister(config) });
|
||||
if (action === "pi-options") return await service.options();
|
||||
if (action === "pi-test") return await service.test();
|
||||
throw new Error("unsupported operator action");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const action = process.argv[2] as OperatorAction | undefined;
|
||||
if (!action || ![
|
||||
"maintenance-activate", "maintenance-deactivate", "maintenance-status", "session-inventory",
|
||||
"workflow-doctor", "pi-options", "pi-test", "effective-settings",
|
||||
].includes(action)) throw new Error("invalid operator action");
|
||||
const result = await runOperatorAction(action, loadConfig(process.env));
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
void main().catch(() => {
|
||||
process.stderr.write("operator command failed\n");
|
||||
process.exitCode = 2;
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { buildApp, type AppWithAuthSessionStore } from "./app.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { formatStartupFailure } from "./startup-error.js";
|
||||
const config = loadConfig(process.env);
|
||||
const app = buildApp(config) as AppWithAuthSessionStore;
|
||||
|
||||
async function start(): Promise<void> {
|
||||
const config = loadConfig(process.env);
|
||||
const app = buildApp(config) as AppWithAuthSessionStore;
|
||||
const sessions = app.thothiiAuthSessionStore;
|
||||
if (sessions) {
|
||||
await sessions.prune();
|
||||
|
||||
@@ -4,9 +4,15 @@ const STARTUP_CAUSES = new Set([
|
||||
"workspace_registry_invalid",
|
||||
]);
|
||||
|
||||
const EXACT_CAUSES = new Map([
|
||||
["authentication configuration is invalid", "auth_config_invalid"],
|
||||
["authentication session state is invalid", "auth_session_store_invalid"],
|
||||
["workspace registry configuration is invalid", "workspace_registry_invalid"],
|
||||
]);
|
||||
|
||||
/** Return one bounded machine cause; never include the original error text or stack. */
|
||||
export function formatStartupFailure(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const cause = STARTUP_CAUSES.has(message) ? message : "startup_unknown";
|
||||
const cause = STARTUP_CAUSES.has(message) ? message : EXACT_CAUSES.get(message) ?? "startup_unknown";
|
||||
return `backend startup failed: ${cause}`;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ test("the session boundary exposes only exact health and authentication protocol
|
||||
expect((await app.inject({ method: "GET", url: "/auth/configured" })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
test("the session boundary retains the exact loopback tht maintenance identity in configured auth modes", async () => {
|
||||
test("loopback maintenance headers can never mint an administrator in configured auth modes", async () => {
|
||||
const app = Fastify();
|
||||
app.addHook("preHandler", authenticateSession({ mode: "local" }));
|
||||
app.get("/private", async (request) => getPrincipal(request));
|
||||
@@ -183,8 +183,8 @@ test("the session boundary retains the exact loopback tht maintenance identity i
|
||||
|
||||
for (const method of ["GET", "POST"] as const) {
|
||||
const response = await app.inject({ method, url: "/private", headers, remoteAddress: "127.0.0.1" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ issuer: "tht", subject: "tht-maintenance", isAdmin: true });
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.body).not.toContain("tht-maintenance");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatStartupFailure } from "../src/startup-error.js";
|
||||
|
||||
@@ -25,4 +29,34 @@ describe("formatStartupFailure", () => {
|
||||
expect(formatted).not.toContain(leaked);
|
||||
}
|
||||
});
|
||||
|
||||
it("sanitizes synchronous configuration failures from the real server subprocess", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "thothii-startup-secret-"));
|
||||
const secret = "startup-password-do-not-log";
|
||||
const authDirectory = join(root, `auth-${secret}`);
|
||||
mkdirSync(authDirectory, { mode: 0o700 });
|
||||
const authFile = join(authDirectory, "auth.yaml");
|
||||
writeFileSync(authFile, `version: 1\nmode: local\npassword: ${secret}\n`, { mode: 0o600 });
|
||||
chmodSync(authDirectory, 0o700);
|
||||
const entrypoint = resolve(process.cwd(), "src/server.ts");
|
||||
|
||||
const result = spawnSync(process.execPath, ["--import", "tsx", entrypoint], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
THT_AUTH_CONFIG_FILE: authFile,
|
||||
THT_AUTH_STATE_ROOT: join(root, "auth-state"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr.trim()).toBe("backend startup failed: auth_config_invalid");
|
||||
expect(`${result.stdout}${result.stderr}`).not.toContain(secret);
|
||||
expect(`${result.stdout}${result.stderr}`).not.toContain(authDirectory);
|
||||
expect(`${result.stdout}${result.stderr}`).not.toContain("server.ts");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user