From 9558eaa50895945c26cf110792e498a13093af6b Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 17 Aug 2026 20:21:42 +0200 Subject: [PATCH] feat(auth): integrate authentication with installation lifecycle --- compose.yaml | 5 + deploy/compose.local.yaml | 1 - deploy/compose.server.yaml | 7 +- deploy/compose.session-server.yaml.example | 1 - deploy/env/local.env.example | 1 + deploy/env/server.env.example | 1 + deploy/psd/operator.env.example | 2 +- deploy/psd/thothii-installation.yaml.example | 2 + deploy/secrets/README.md | 6 +- deploy/secrets/thothii.secrets.example | 4 + .../examples/thothii-installation.local.yaml | 2 + .../examples/thothii-installation.server.yaml | 2 + scripts/task13-runtime-fixture-check.ts | 20 +- scripts/test-canonical-install-compose.sh | 23 ++- scripts/test-compose-secret-policy.sh | 17 ++ scripts/test-default-compose.sh | 22 +- scripts/test-task13-runtime-fixtures.sh | 7 + scripts/unified-deployment-smoke.sh | 194 ++++++++++++------ tools/tht/cmd/tht/main.go | 20 ++ tools/tht/cmd/tht/main_test.go | 18 +- tools/tht/internal/backup/create.go | 88 +++++++- tools/tht/internal/backup/create_test.go | 69 +++++++ tools/tht/internal/backup/restore.go | 131 +++++++++--- tools/tht/internal/backup/restore_test.go | 68 ++++++ tools/tht/internal/doctor/report.go | 4 +- tools/tht/internal/doctor/report_test.go | 13 +- tools/tht/internal/setup/files_test.go | 3 + tools/tht/internal/setup/request.go | 10 + tools/tht/internal/setup/run.go | 74 +++++++ tools/tht/internal/setup/run_test.go | 61 +++++- 30 files changed, 756 insertions(+), 120 deletions(-) diff --git a/compose.yaml b/compose.yaml index 59e77fdb..f395f575 100644 --- a/compose.yaml +++ b/compose.yaml @@ -23,6 +23,8 @@ services: THT_WORKSPACE_SECRET_ROOTS: /run/secrets THT_SECRETS_FILE: /run/secrets/thothii.secrets THT_PI_AUTH_FILE: /home/thoth/.pi/agent/auth.json + THT_AUTH_CONFIG_FILE: /run/thothii-auth/auth.yaml + THT_AUTH_STATE_ROOT: /data/auth THT_DB_NAME: ${THT_DB_NAME:-} THT_DWH_REST_URL: ${THT_DWH_REST_URL:-} THT_LLM_URL: ${THT_LLM_URL:-} @@ -40,6 +42,8 @@ services: - workspace-registry:/data/workspace-registry - workspace-secrets:/data/workspace-secrets - sessions:/data/sessions + - ${THT_AUTH_CONFIG_ROOT:?set THT_AUTH_CONFIG_ROOT}:/run/thothii-auth:ro + - auth-state:/data/auth secrets: - source: thothii_secrets target: thothii.secrets @@ -204,6 +208,7 @@ volumes: sessions: qdrant-data: embedding-models: + auth-state: secrets: thothii_secrets: diff --git a/deploy/compose.local.yaml b/deploy/compose.local.yaml index 833d6bae..75d3ae6f 100644 --- a/deploy/compose.local.yaml +++ b/deploy/compose.local.yaml @@ -1,7 +1,6 @@ services: core: environment: - AUTH_MODE: none NODE_ENV: development THT_WORKSPACE_INSTALLATION_ID: local ports: diff --git a/deploy/compose.server.yaml b/deploy/compose.server.yaml index 98c5365e..4bde7877 100644 --- a/deploy/compose.server.yaml +++ b/deploy/compose.server.yaml @@ -1,7 +1,6 @@ services: core: environment: - AUTH_MODE: upstream THOTH_PUBLIC_EXPOSURE: "true" THT_DATA_ROOT: /data THT_WORKSPACE_INSTALLATION_ID: server @@ -11,6 +10,10 @@ services: - type: bind source: ${THT_DATA_ROOT:?set THT_DATA_ROOT} target: /data + - type: bind + source: ${THT_AUTH_CONFIG_ROOT:?set THT_AUTH_CONFIG_ROOT} + target: /run/thothii-auth + read_only: true - type: bind source: ${THT_PI_STATE_ROOT:?set THT_PI_STATE_ROOT} target: /home/thoth/.pi @@ -30,6 +33,8 @@ services: source: ${THT_WORKSPACE_REGISTRY_ROOT:?set THT_WORKSPACE_REGISTRY_ROOT} target: /data/workspace-registry restart: unless-stopped + # Deprecated migration adapter: only use this when no auth.yaml is mounted yet. + # AUTH_MODE: upstream frontend: ports: diff --git a/deploy/compose.session-server.yaml.example b/deploy/compose.session-server.yaml.example index 31643170..43821be6 100644 --- a/deploy/compose.session-server.yaml.example +++ b/deploy/compose.session-server.yaml.example @@ -3,7 +3,6 @@ services: core: environment: - AUTH_MODE: upstream THOTH_PUBLIC_EXPOSURE: "true" THT_SESSION_STORAGE: postgres THT_CONFIG: /app/harness/workspaces/server-sessions.yaml diff --git a/deploy/env/local.env.example b/deploy/env/local.env.example index 483869ca..d7627d2a 100644 --- a/deploy/env/local.env.example +++ b/deploy/env/local.env.example @@ -6,6 +6,7 @@ THOTH_CORE_HTTP_PORT=8787 MAX_PI_PROCESSES=4 PI_AUTH_FILE=/absolute/path/to/pi-auth.json THT_SECRETS_FILE=/absolute/path/to/thothii.secrets +THT_AUTH_CONFIG_ROOT=/absolute/path/to/thothii-auth THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git THT_WORKSPACE_GIT_BRANCH=main diff --git a/deploy/env/server.env.example b/deploy/env/server.env.example index 22e6f40f..152c3c2e 100644 --- a/deploy/env/server.env.example +++ b/deploy/env/server.env.example @@ -5,6 +5,7 @@ THOTH_HTTP_PORT=8080 MAX_PI_PROCESSES=4 PI_AUTH_FILE=/absolute/path/to/pi-auth.json THT_SECRETS_FILE=/absolute/path/to/thothii.secrets +THT_AUTH_CONFIG_ROOT=/absolute/path/to/thothii-auth THT_DATA_ROOT=/srv/thothii/data THT_PI_STATE_ROOT=/srv/thothii/pi-state diff --git a/deploy/psd/operator.env.example b/deploy/psd/operator.env.example index 612e2dca..ebdd2a64 100644 --- a/deploy/psd/operator.env.example +++ b/deploy/psd/operator.env.example @@ -9,6 +9,7 @@ THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=/deploy/psd/secrets/git-known-hosts # App THT_SECRETS_FILE=/deploy/psd/secrets/thothii.secrets PI_AUTH_FILE=/deploy/psd/secrets/pi-auth.json +THT_AUTH_CONFIG_ROOT=/deploy/psd/auth # DWH and Evidence credentials are entered later in Workspace management and stored encrypted # by the backend. They do not depend on host filesystem paths. @@ -19,7 +20,6 @@ PI_THINKING=medium # App defaults THT_DWH_PRECHECK=true -AUTH_MODE=none THOTH_PUBLIC_EXPOSURE=false MAX_PI_PROCESSES=4 THOTH_HTTP_PORT=8080 diff --git a/deploy/psd/thothii-installation.yaml.example b/deploy/psd/thothii-installation.yaml.example index 2811a5aa..d4afc2c3 100644 --- a/deploy/psd/thothii-installation.yaml.example +++ b/deploy/psd/thothii-installation.yaml.example @@ -8,5 +8,7 @@ workspaceRepository: remote: git@github.com:mptyl/tht-workspace-psd.git branch: main access: ssh +authentication: + configDirectory: "/projects/ThothII/deploy/psd/auth" overrides: - "/projects/ThothII/deploy/compose.git-ssh.yaml" diff --git a/deploy/secrets/README.md b/deploy/secrets/README.md index 2a352fe8..23252411 100644 --- a/deploy/secrets/README.md +++ b/deploy/secrets/README.md @@ -9,8 +9,10 @@ chmod 600 deploy/secrets/thothii.secrets ``` The file uses strict `KEY=VALUE` lines (comments and blank lines are allowed). The supported -keys are `THT_MODEL_API_KEY`, `THT_DWH_API_KEY`, `THT_CA`, and `THT_SSL_CA`. Values must be -non-empty and contain no whitespace. Do not put secrets +keys are `THT_MODEL_API_KEY`, `THT_DWH_API_KEY`, `THT_CA`, `THT_SSL_CA`, +`THT_OIDC_CLIENT_SECRET`, and `THT_AUTHENTIK_API_TOKEN`. The two authentication keys are fixed +empty entries for local authentication and must be populated only in a protected OIDC installation. +Other configured values must be non-empty and contain no whitespace. Do not put secrets in the root `.env`, workspace YAML, URLs, logs, or rendered Compose output. Do not add vector or embedding endpoint credentials to the bundle. Active operator manuals use diff --git a/deploy/secrets/thothii.secrets.example b/deploy/secrets/thothii.secrets.example index f6013204..40c633db 100644 --- a/deploy/secrets/thothii.secrets.example +++ b/deploy/secrets/thothii.secrets.example @@ -10,3 +10,7 @@ # Optional CA material/path understood by the configured adapter. # THT_CA=/run/secrets/ca-chain.pem + +# OIDC/Authentik references. Keep these fixed keys empty until OIDC is configured. +THT_OIDC_CLIENT_SECRET= +THT_AUTHENTIK_API_TOKEN= diff --git a/docs/install/examples/thothii-installation.local.yaml b/docs/install/examples/thothii-installation.local.yaml index ef4a9e5a..193746f7 100644 --- a/docs/install/examples/thothii-installation.local.yaml +++ b/docs/install/examples/thothii-installation.local.yaml @@ -7,5 +7,7 @@ workspaceRepository: remote: git@git.example.com:organization/workspaces.git branch: main access: ssh +authentication: + configDirectory: "/absolute/path/to/thothii-auth" overrides: - "/absolute/path/to/ThothII/deploy/compose.git-ssh.yaml" diff --git a/docs/install/examples/thothii-installation.server.yaml b/docs/install/examples/thothii-installation.server.yaml index 5f6e1dff..ab3e2912 100644 --- a/docs/install/examples/thothii-installation.server.yaml +++ b/docs/install/examples/thothii-installation.server.yaml @@ -7,6 +7,8 @@ workspaceRepository: remote: git@git.example.com:organization/workspaces.git branch: main access: ssh +authentication: + configDirectory: "/absolute/path/to/thothii-auth" overrides: - "/absolute/path/to/ThothII/deploy/compose.session-server.yaml.example" - "/absolute/path/to/ThothII/deploy/compose.git-ssh.yaml" diff --git a/scripts/task13-runtime-fixture-check.ts b/scripts/task13-runtime-fixture-check.ts index 5d2de1bc..83e1a461 100644 --- a/scripts/task13-runtime-fixture-check.ts +++ b/scripts/task13-runtime-fixture-check.ts @@ -1,4 +1,4 @@ -import { constants, accessSync, readFileSync, statSync } from "node:fs"; +import { constants, accessSync, readFileSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { createRequire } from "node:module"; import { resolveRuntimeBindings } from "../backend/src/workspaces/bindings.js"; @@ -28,7 +28,12 @@ for (const [name, service, expectedExpose] of [ ["qdrant", qdrant, "6333"], ["embedding", embedding, "11434"], ] as const) { - if ((service.ports || []).length !== 0) throw new Error(`${name} must not publish host ports`); + const localQdrantDashboard = name === "qdrant" && profile === "local" + && (service.ports || []).length === 1 + && service.ports[0].host_ip === "127.0.0.1" && Number(service.ports[0].target) === 6333; + if ((service.ports || []).length !== 0 && !localQdrantDashboard) { + throw new Error(`${name} must not publish host ports outside the local Qdrant dashboard`); + } if ((service.expose || []).join(",") !== expectedExpose) { throw new Error(`${name} must expose only ${expectedExpose}`); } @@ -139,8 +144,9 @@ for (const target of [ } const resolverEnvironment = { ...core.environment }; -resolverEnvironment.THT_WS_TASK13_SMOKE_DWH_PASSWORD_FILE = runtimePasswordMounts[0].source; -const bindings = resolveRuntimeBindings(workspace, resolverEnvironment, [dirname(runtimePasswordMounts[0].source)]); +const runtimePasswordSource = realpathSync(runtimePasswordMounts[0].source); +resolverEnvironment.THT_WS_TASK13_SMOKE_DWH_PASSWORD_FILE = runtimePasswordSource; +const bindings = resolveRuntimeBindings(workspace, resolverEnvironment, [dirname(runtimePasswordSource)]); for (const [role, binding] of Object.entries(bindings)) { if ((binding as any).missing.length !== 0) { throw new Error(`workspace resolver reports missing ${role} bindings: ${(binding as any).missing.join(",")}`); @@ -150,10 +156,14 @@ const runtime = parse(renderRuntimeConfig(workspace, bindings, { sessions: "/data/sessions", artifacts: "/data/artifacts", indexes: "/data/indexes", +}, { + workspaceId: "task13-smoke", + workspaceRevision: "task13-fixture", + revisionContentRoot: join(dirname(workspacePath), "task13-fixture"), })); if (runtime.database.host !== expected.THT_WS_TASK13_SMOKE_DWH_HOST || runtime.database.user !== expected.THT_WS_TASK13_SMOKE_DWH_USER - || runtime.database.password_file !== runtimePasswordMounts[0].source) { + || runtime.database.password_file !== runtimePasswordSource) { throw new Error("workspace resolver produced the wrong DWH runtime"); } if (runtime.resources?.vector?.base_url !== "http://qdrant:6333" diff --git a/scripts/test-canonical-install-compose.sh b/scripts/test-canonical-install-compose.sh index 462524e9..f9ccfa23 100755 --- a/scripts/test-canonical-install-compose.sh +++ b/scripts/test-canonical-install-compose.sh @@ -20,7 +20,8 @@ done printf '%s\n' '{}' >"$tmp/pi-auth.json" printf '%s\n' 'THT_MODEL_API_KEY=fixture-model-api-key' >"$tmp/thothii.secrets" chmod 0600 "$tmp/pi-auth.json" "$tmp/thothii.secrets" -mkdir -p "$tmp/data" "$tmp/pi-state" "$tmp/workspace-registry" +mkdir -p "$tmp/data" "$tmp/pi-state" "$tmp/workspace-registry" "$tmp/auth" +chmod 0700 "$tmp/auth" "$root/scripts/prepare-server-pi-state.sh" "$tmp/pi-state" "$(id -u)" "$(id -g)" >/dev/null printf '%s\n' 'fixture-session-password' >"$tmp/session-runtime-password" printf '%s\n' 'fixture-session-migrator-password' >"$tmp/session-migrator-password" @@ -34,7 +35,8 @@ for profile in local server; do printf '%s\n' \ 'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git' \ "PI_AUTH_FILE=$tmp/pi-auth.json" \ - "THT_SECRETS_FILE=$tmp/thothii.secrets" + "THT_SECRETS_FILE=$tmp/thothii.secrets" \ + "THT_AUTH_CONFIG_ROOT=$tmp/auth" if [[ "$profile" == server ]]; then printf '%s\n' \ "THT_DATA_ROOT=$tmp/data" \ @@ -73,6 +75,23 @@ if (!config.services.core.volumes?.some( )) { throw new Error(profile + ": install stack lacks the read-only Pi auth file"); } +const authConfig = config.services.core.volumes?.filter((mount) => mount.target === "/run/thothii-auth") || []; +if (authConfig.length !== 1 || authConfig[0].type !== "bind" || !authConfig[0].read_only) { + throw new Error(profile + ": core must receive one read-only authentication config bind"); +} +if (profile === "local") { + const authState = config.services.core.volumes?.filter((mount) => mount.target === "/data/auth") || []; + if (authState.length !== 1 || authState[0].type !== "volume" || authState[0].source !== "auth-state") { + throw new Error("local: core must receive the auth-state volume"); + } +} else if (!config.services.core.volumes?.some((mount) => mount.target === "/data" && mount.type === "bind")) { + throw new Error("server: core must preserve the whole /data bind that contains auth state"); +} +if ((config.services["workspace-maintenance"]?.volumes || []).some( + (mount) => mount.target === "/run/thothii-auth" || mount.target === "/data/auth", +)) { + throw new Error(profile + ": workspace-maintenance received authentication data"); +} if ((config.services.frontend.secrets || []).length !== 0) { throw new Error(profile + ": frontend received runtime secrets"); } diff --git a/scripts/test-compose-secret-policy.sh b/scripts/test-compose-secret-policy.sh index d71ef146..044976da 100755 --- a/scripts/test-compose-secret-policy.sh +++ b/scripts/test-compose-secret-policy.sh @@ -56,6 +56,19 @@ if (core.environment?.THT_SECRETS_FILE !== "/run/secrets/thothii.secrets") { if (core.environment?.THT_PI_AUTH_FILE !== "/home/thoth/.pi/agent/auth.json") { throw new Error(`${name}: core does not declare the mounted Pi authentication source`); } +if (core.environment?.THT_AUTH_CONFIG_FILE !== "/run/thothii-auth/auth.yaml" + || core.environment?.THT_AUTH_STATE_ROOT !== "/data/auth") { + throw new Error(`${name}: core authentication paths do not use the canonical locations`); +} +const authConfig = (core.volumes || []).filter((mount) => mount.target === "/run/thothii-auth"); +if (authConfig.length !== 1 || authConfig[0].type !== "bind" || !authConfig[0].read_only) { + throw new Error(`${name}: core must receive exactly one read-only authentication config bind`); +} +if ((config.services["workspace-maintenance"]?.volumes || []).some( + (mount) => mount.target === "/run/thothii-auth" || mount.target === "/data/auth", +)) { + throw new Error(`${name}: workspace-maintenance received authentication data`); +} if ((config.services.frontend?.secrets || []).length !== 0) { throw new Error(`${name}: frontend must not receive runtime secrets`); } @@ -113,10 +126,13 @@ write_secret "$fixture_root/https-credentials" 'fixture-https-credentials' write_secret "$fixture_root/https-ca.pem" 'fixture-https-ca' write_secret "$fixture_root/dwh-password" 'fixture-dwh-password' write_secret "$fixture_root/vector-api-key" 'fixture-vector-api-key' +mkdir -p "$fixture_root/auth" +chmod 0700 "$fixture_root/auth" printf '%s\n' \ 'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git' \ "PI_AUTH_FILE=$fixture_root/pi-auth.json" \ "THT_SECRETS_FILE=$fixture_root/thothii.secrets" \ + "THT_AUTH_CONFIG_ROOT=$fixture_root/auth" \ "THT_WORKSPACE_BINDINGS_ENV_FILE=$fixture_root/workspace-bindings.env" \ "THT_WORKSPACE_GIT_SSH_KEY_FILE=$fixture_root/ssh-private-key" \ "THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=$fixture_root/ssh-known-hosts" \ @@ -188,6 +204,7 @@ printf '%s\n' \ 'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/thoth-workspaces.git' \ "PI_AUTH_FILE=$fixture_root/pi-auth.json" \ "THT_SECRETS_FILE=$fixture_root/thothii.secrets" \ + "THT_AUTH_CONFIG_ROOT=$fixture_root/auth" \ "THT_WS_NORTH_STAR_RESEARCH_DWH_PASSWORD_SOURCE=$fixture_root/dwh-password" \ "THT_WS_NORTH_STAR_RESEARCH_VECTOR_API_KEY_SOURCE=$fixture_root/vector-api-key" \ >"$fixture_root/operator-with-vector.env" diff --git a/scripts/test-default-compose.sh b/scripts/test-default-compose.sh index fbf15dbc..4b968dfd 100755 --- a/scripts/test-default-compose.sh +++ b/scripts/test-default-compose.sh @@ -24,7 +24,7 @@ if (services.join(",") !== "core,embedding,embedding-model-init,frontend,qdrant" if (/omics_portal|chirone|localllm_default|\/home\/chirone/i.test(JSON.stringify(config))) { throw new Error("default Compose contains application-specific coupling"); } -for (const volume of ["settings", "pi-state", "workspace-registry", "sessions", "qdrant-data", "embedding-models"]) { +for (const volume of ["settings", "pi-state", "workspace-registry", "sessions", "auth-state", "qdrant-data", "embedding-models"]) { if (!config.volumes || !config.volumes[volume]) throw new Error(`missing required volume: ${volume}`); } const core = config.services.core; @@ -35,9 +35,12 @@ const modelInit = config.services["embedding-model-init"]; if (!frontend.ports?.some((port) => port.host_ip === "127.0.0.1")) { throw new Error("local frontend must publish a loopback port"); } -for (const service of [qdrant, embedding, modelInit]) { +for (const service of [embedding, modelInit]) { if ((service.ports || []).length !== 0) throw new Error("private semantic services must not publish host ports"); } +if (!qdrant.ports?.some((port) => port.host_ip === "127.0.0.1" && Number(port.target) === 6333)) { + throw new Error("local Qdrant dashboard must publish only its loopback port"); +} if ((qdrant.expose || []).join(",") !== "6333") throw new Error("qdrant must expose only 6333"); if ((embedding.expose || []).join(",") !== "11434") throw new Error("embedding must expose only 11434"); if (!qdrant.healthcheck) throw new Error("qdrant must define a healthcheck"); @@ -53,8 +56,9 @@ if (modelInit.image !== "ollama/ollama:0.32.0@sha256:57f573b47f1f71ebb445789f279 } const env = core.environment || {}; for (const [key, value] of Object.entries({ - AUTH_MODE: "none", THT_WORKSPACE_INSTALLATION_ID: "local", + THT_AUTH_CONFIG_FILE: "/run/thothii-auth/auth.yaml", + THT_AUTH_STATE_ROOT: "/data/auth", THT_INTERNAL_QDRANT_URL: "http://qdrant:6333", THT_INTERNAL_EMBEDDING_URL: "http://embedding:11434", THT_INTERNAL_EMBEDDING_MODEL: "qwen3-embedding:0.6b", @@ -62,6 +66,18 @@ for (const [key, value] of Object.entries({ })) { if (env[key] !== value) throw new Error(`unexpected core ${key}: ${env[key]}`); } +const authConfigMounts = (core.volumes || []).filter((mount) => mount.target === "/run/thothii-auth"); +if (authConfigMounts.length !== 1 || authConfigMounts[0].type !== "bind" || !authConfigMounts[0].read_only) { + throw new Error("core must receive exactly one read-only authentication configuration bind"); +} +const authStateMounts = (core.volumes || []).filter((mount) => mount.target === "/data/auth"); +if (authStateMounts.length !== 1 || authStateMounts[0].type !== "volume" || authStateMounts[0].source !== "auth-state") { + throw new Error("core must receive exactly one auth-state volume"); +} +const maintenanceMounts = config.services["workspace-maintenance"]?.volumes || []; +if (maintenanceMounts.some((mount) => mount.target === "/run/thothii-auth" || mount.target === "/data/auth")) { + throw new Error("workspace-maintenance must not receive authentication configuration or state"); +} for (const forbidden of ["THT_VEC_REST_URL", "THT_VEC_WRITE_REST_URL", "THT_OLLAMA_URL"]) { if (Object.hasOwn(env, forbidden) && env[forbidden] !== "") { throw new Error(`core must not require external semantic binding ${forbidden}`); diff --git a/scripts/test-task13-runtime-fixtures.sh b/scripts/test-task13-runtime-fixtures.sh index d87fff29..b8027e67 100755 --- a/scripts/test-task13-runtime-fixtures.sh +++ b/scripts/test-task13-runtime-fixtures.sh @@ -31,6 +31,13 @@ TASK13_LOG="$fixture/task13.log" TASK13_INSTALLATION="$fixture/thothii-installation.yaml" TASK13_PI_AUTH="$fixture/pi-auth.json" TASK13_SECRETS="$fixture/thothii.secrets" +TASK13_AUTH_ROOT="$fixture/auth" +TASK13_AUTH_PASSWORD_FILE="$fixture/local-auth-password" +TASK13_AUTH_ADMIN=task13-admin +TASK13_AUTH_PASSWORD="fixture-auth-password-$profile" +TASK13_OIDC_CLIENT_SECRET="fixture-oidc-client-$profile" +TASK13_AUTHENTIK_API_TOKEN="fixture-authentik-token-$profile" +TASK13_FRONTEND_PORT=18080 TASK13_SESSION_RUNTIME_PASSWORD="$fixture/runtime-password" TASK13_PI_MODELS="$fixture/models.json" TASK13_PI_SETTINGS="$fixture/settings.json" diff --git a/scripts/unified-deployment-smoke.sh b/scripts/unified-deployment-smoke.sh index 060ef5ac..a8a6d669 100755 --- a/scripts/unified-deployment-smoke.sh +++ b/scripts/unified-deployment-smoke.sh @@ -169,11 +169,12 @@ task13_compose_logged() { task13_write_environment() { local remote="$1" { - printf 'THOTH_HTTP_PORT=0\n' + printf 'THOTH_HTTP_PORT=%s\n' "$TASK13_FRONTEND_PORT" printf 'THOTH_CORE_HTTP_PORT=0\n' printf 'MAX_PI_PROCESSES=2\n' printf 'PI_AUTH_FILE=%s\n' "$TASK13_PI_AUTH" printf 'THT_SECRETS_FILE=%s\n' "$TASK13_SECRETS" + printf 'THT_AUTH_CONFIG_ROOT=%s\n' "$TASK13_AUTH_ROOT" printf 'THT_WORKSPACE_GIT_REMOTE=%s\n' "$remote" printf 'THT_WORKSPACE_GIT_BRANCH=%s\n' "$TASK13_BRANCH" printf 'THT_LLM_URL=http://%s:9000/v1\n' "$TASK13_LLM_CONTAINER" @@ -185,8 +186,11 @@ task13_write_fixture_files() { printf '{}\n' >"$TASK13_PI_AUTH" printf 'THT_MODEL_API_KEY=%s\n' "$TASK13_SECRET_VALUE" >"$TASK13_SECRETS" printf '%s' "task13-runtime-password-$TASK13_RUN_ID" >"$TASK13_SESSION_RUNTIME_PASSWORD" + printf '%s' "$TASK13_AUTH_PASSWORD" >"$TASK13_AUTH_PASSWORD_FILE" + mkdir -p "$TASK13_AUTH_ROOT" chmod 0644 "$TASK13_PI_AUTH" - chmod 0600 "$TASK13_SECRETS" "$TASK13_SESSION_RUNTIME_PASSWORD" + chmod 0700 "$TASK13_AUTH_ROOT" + chmod 0600 "$TASK13_SECRETS" "$TASK13_SESSION_RUNTIME_PASSWORD" "$TASK13_AUTH_PASSWORD_FILE" cat >"$TASK13_PI_MODELS" <"$TASK13_PI_AUTH" - printf 'THT_MODEL_API_KEY=%s\n' "$TASK13_SECRET_VALUE" >"$TASK13_SECRETS" + printf 'THT_MODEL_API_KEY=%s\nTHT_OIDC_CLIENT_SECRET=%s\nTHT_AUTHENTIK_API_TOKEN=%s\n' \ + "$TASK13_SECRET_VALUE" "$TASK13_OIDC_CLIENT_SECRET" "$TASK13_AUTHENTIK_API_TOKEN" >"$TASK13_SECRETS" printf '%s' "$TASK13_SESSION_PASSWORD" >"$TASK13_SESSION_RUNTIME_PASSWORD" printf '%s' "$TASK13_SESSION_MIGRATOR_PASSWORD" >"$TASK13_SESSION_MIGRATOR_PASSWORD_FILE" cat >"$TASK13_SESSION_CA" <<'EOF' @@ -383,10 +397,12 @@ EOF chmod 0600 "$TASK13_SERVER_WORKSPACE_CONFIG" mkdir -p "$TASK13_SERVER_DATA" "$TASK13_SERVER_PI_STATE" "$TASK13_SERVER_REGISTRY" + mkdir -p "$TASK13_AUTH_ROOT" "$TASK13_ROOT/scripts/prepare-server-pi-state.sh" \ "$TASK13_SERVER_PI_STATE" "$(id -u)" "$(id -g)" >>"$TASK13_LOG" chmod 0777 "$TASK13_SERVER_DATA" "$TASK13_SERVER_PI_STATE" \ "$TASK13_SERVER_PI_STATE/agent" "$TASK13_SERVER_REGISTRY" + chmod 0700 "$TASK13_AUTH_ROOT" data_root="$TASK13_SERVER_DATA" pi_root="$TASK13_SERVER_PI_STATE" registry_root="$TASK13_SERVER_REGISTRY" @@ -449,6 +465,7 @@ EOF printf 'MAX_PI_PROCESSES=2\n' printf 'PI_AUTH_FILE=%s\n' "$TASK13_PI_AUTH" printf 'THT_SECRETS_FILE=%s\n' "$TASK13_SECRETS" + printf 'THT_AUTH_CONFIG_ROOT=%s\n' "$TASK13_AUTH_ROOT" printf 'THT_WORKSPACE_GIT_REMOTE=/fixtures/remote.git\n' printf 'THT_WORKSPACE_GIT_BRANCH=%s\n' "$TASK13_BRANCH" printf 'THT_DATA_ROOT=%s\n' "$data_root" @@ -467,6 +484,18 @@ EOF printf 'THT_LLM_URL=https://llm.task13.invalid/v1\n' } >"$TASK13_ENV_FILE" chmod 0600 "$TASK13_ENV_FILE" + + cat >"$TASK13_INSTALLATION" <"$login_body" + chmod 0600 "$cookie_jar" "$login_body" 2>/dev/null || chmod 0600 "$login_body" + + task13_run_logged "local auth configuration" curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ + --max-time "$TASK13_CURL_MAX_TIME" --fail --silent --show-error "http://$frontend/api/auth/config" + task13_run_logged "local auth login" curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ + --max-time "$TASK13_CURL_MAX_TIME" --fail --silent --show-error --cookie-jar "$cookie_jar" \ + -H "Origin: http://$frontend" -H 'content-type: application/json' --data-binary "@$login_body" \ + "http://$frontend/api/auth/local/login" + me="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \ + --fail --silent --show-error --cookie "$cookie_jar" "http://$frontend/api/me")" + node -e 'const value=JSON.parse(process.argv[1]); if(value.issuer!=="local"||value.session?.remembered!==true||typeof value.csrfToken!=="string") process.exit(1)' "$me" \ + || task13_fail "local login did not create a remembered authenticated session" + csrf="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).csrfToken)' "$me")" + task13_compose_logged "remembered local auth core restart" up --detach --force-recreate --wait --wait-timeout 120 core + me="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \ + --fail --silent --show-error --cookie "$cookie_jar" "http://$frontend/api/me")" + node -e 'const value=JSON.parse(process.argv[1]); if(value.session?.remembered!==true) process.exit(1)' "$me" \ + || task13_fail "remembered local session did not survive a core restart" + csrf="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).csrfToken)' "$me")" + task13_run_logged "local auth Pi management" curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ + --max-time 45 --fail --silent --show-error --cookie "$cookie_jar" \ + -H "Origin: http://$frontend" -H "x-thothii-csrf: $csrf" -X POST "http://$frontend/api/pi-management/test" + task13_run_logged "local auth logout" curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ + --max-time "$TASK13_CURL_MAX_TIME" --fail --silent --show-error --cookie "$cookie_jar" \ + -H "Origin: http://$frontend" -H "x-thothii-csrf: $csrf" -X POST "http://$frontend/api/auth/logout" + unauthenticated="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" --max-time "$TASK13_CURL_MAX_TIME" \ + --silent --output /dev/null --write-out '%{http_code}' --cookie "$cookie_jar" "http://$frontend/api/me")" + [[ "$unauthenticated" == 401 ]] || task13_fail "local logout did not revoke the remembered session" +} + task13_assert_runtime() { local frontend expected_pi actual_pi core_id frontend="$(task13_frontend_address)" @@ -668,25 +756,11 @@ task13_assert_runtime() { core_id="$(task13_core_id)" [[ "$(docker inspect --format '{{ index .Config.Labels "io.thothii.task13.run" }}' "$core_id")" == "$TASK13_RUN_ID" ]] \ || task13_fail "core lacks the explicit Task 13 resource label" - task13_compose_logged "internal Pi provider smoke" exec -T core \ - curl --connect-timeout 3 --max-time 45 -fsS -X POST \ - -H 'x-thoth-principal-issuer: tht' \ - -H 'x-thoth-principal-subject: tht-maintenance' \ - -H 'x-thoth-principal-display-name: Tht maintenance' \ - -H 'x-thoth-is-admin: 1' \ - http://127.0.0.1:8787/pi-management/test + task13_assert_maintenance_auth_isolation + task13_assert_local_auth_lifecycle task13_run_logged "tht Pi doctor" "$TASK13_THT" --installation "$TASK13_INSTALLATION" pi doctor } -task13_server_auth_headers() { - TASK13_SERVER_AUTH_HEADERS=( - -H 'x-thoth-trusted-principal-issuer: task13-proxy' - -H 'x-thoth-trusted-principal-subject: task13-user' - -H 'x-thoth-trusted-principal-display-name: Task 13 User' - -H 'x-thoth-trusted-is-admin: 0' - ) -} - task13_report_server_workspace_failure() { local status="$1" response="$2" printf 'authenticated server /api/workspaces returned HTTP %s\n' "$status" >&2 @@ -725,7 +799,7 @@ task13_report_server_workspace_failure() { } task13_assert_server_runtime() { - local frontend unauthenticated authenticated authenticated_status session_status core_id frontend_id + local frontend unauthenticated trusted_header_status session_status diagnostics diagnostic_status core_id frontend_id local expected_core_image expected_frontend_image frontend="$(task13_frontend_address)" task13_run_logged "server frontend health" curl \ @@ -744,7 +818,9 @@ task13_assert_server_runtime() { [[ "$(docker inspect --format '{{.Image}}' "$frontend_id")" == "$expected_frontend_image" ]] \ || task13_fail "server frontend did not use the smoke-built frontend image" task13_compose exec -T core sh -ceu ' - test "$AUTH_MODE" = upstream + test -r /run/thothii-auth/auth.yaml + test -d /data/auth + test -z "${AUTH_MODE+x}" test "$THT_SESSION_STORAGE" = postgres test -r /run/secrets/thothii.secrets test -r /run/secrets/session_runtime_password @@ -757,37 +833,41 @@ task13_assert_server_runtime() { || task13_fail "server profile did not bind the disposable Pi state root" task13_mount_fingerprint | grep -Fq '/data/workspace-registry = bind :' \ || task13_fail "server profile did not bind the disposable registry root" + task13_assert_maintenance_auth_isolation unauthenticated="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ --max-time "$TASK13_CURL_MAX_TIME" --silent --output /dev/null --write-out '%{http_code}' \ "http://$frontend/api/workspaces")" - [[ "$unauthenticated" == 401 ]] || task13_fail "server profile did not enforce upstream auth" - authenticated="$TASK13_TMP/server-workspaces.out" - task13_server_auth_headers - if ! authenticated_status="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ - --max-time "$TASK13_CURL_MAX_TIME" --silent --show-error --output "$authenticated" \ - --write-out '%{http_code}' "${TASK13_SERVER_AUTH_HEADERS[@]}" \ - "http://$frontend/api/workspaces")"; then - task13_report_server_workspace_failure "${authenticated_status:-transport-error}" "$authenticated" - task13_fail "authenticated server workspace request failed" - fi - if [[ "$authenticated_status" != 200 ]]; then - task13_report_server_workspace_failure "$authenticated_status" "$authenticated" - task13_fail "authenticated server workspace route returned an unexpected status" - fi - grep -Fq 'Task 13 Smoke' "$authenticated" \ - || task13_fail "authenticated server route did not expose the disposable registry" + [[ "$unauthenticated" == 401 ]] || task13_fail "server profile did not enforce OIDC authentication" + trusted_header_status="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ + --max-time "$TASK13_CURL_MAX_TIME" --silent --output /dev/null --write-out '%{http_code}' \ + -H 'x-thoth-trusted-principal-issuer: task13-proxy' \ + -H 'x-thoth-trusted-principal-subject: task13-user' \ + -H 'x-thoth-trusted-principal-display-name: Task 13 User' \ + -H 'x-thoth-trusted-is-admin: 0' \ + "http://$frontend/api/workspaces")" + [[ "$trusted_header_status" == 401 ]] || task13_fail "server accepted retired trusted identity headers" session_status="$(curl --connect-timeout "$TASK13_CURL_CONNECT_TIMEOUT" \ --max-time "$TASK13_CURL_MAX_TIME" --silent --output "$TASK13_TMP/server-sessions.out" \ --write-out '%{http_code}' \ - "${TASK13_SERVER_AUTH_HEADERS[@]}" \ "http://$frontend/api/sessions")" - [[ "$session_status" == 503 ]] \ - || task13_fail "disposable unavailable session dependency did not fail closed with 503" + [[ "$session_status" == 401 ]] \ + || task13_fail "server session route did not fail closed before OIDC authentication" if grep -Fq "$TASK13_SECRET_VALUE" "$TASK13_TMP/server-sessions.out"; then task13_fail "server session failure exposed the fixture secret" fi + diagnostics="$TASK13_TMP/server-auth-diagnostics.json" + set +e + "$TASK13_THT" --installation "$TASK13_INSTALLATION" auth check --json >"$diagnostics" 2>>"$TASK13_LOG" + diagnostic_status=$? + set -e + [[ "$diagnostic_status" == 1 ]] || task13_fail "fake OIDC diagnostics did not fail closed" + node -e 'const value=JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if(value.mode!=="oidc"||value.ready!==false||!value.checks.some((item)=>item.code==="oidc_discovery_unreachable")) process.exit(1)' "$diagnostics" \ + || task13_fail "fake OIDC fixture did not produce the expected static diagnostic" + if grep -Fq "$TASK13_OIDC_CLIENT_SECRET" "$diagnostics" || grep -Fq "$TASK13_AUTHENTIK_API_TOKEN" "$diagnostics"; then + task13_fail "OIDC diagnostics exposed a fixture secret" + fi } task13_registry_status() { @@ -1535,24 +1615,6 @@ task13_self_test_server_release_contract() { || task13_fail "workflow lacks an outer timeout for the Linux server smoke" } -task13_self_test_server_auth_hop_contract() { - local joined - task13_server_auth_headers - joined="${TASK13_SERVER_AUTH_HEADERS[*]}" - for header in \ - x-thoth-trusted-principal-issuer \ - x-thoth-trusted-principal-subject \ - x-thoth-trusted-principal-display-name \ - x-thoth-trusted-is-admin; do - [[ "$joined" == *"$header:"* ]] \ - || task13_fail "server smoke omits trusted frontend hop header: $header" - done - [[ "$joined" == *'x-thoth-trusted-is-admin: 0'* ]] \ - || task13_fail "server smoke admin claim is not the exact non-admin value" - [[ "$joined" != *'x-thoth-principal-issuer:'* ]] \ - || task13_fail "server smoke sends public identity headers to the frontend hop" -} - task13_self_test_source_contract() { local root host_network push_command registry_function workflow uses_count pinned_uses_count root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" @@ -1616,7 +1678,6 @@ task13_self_test() { task13_self_test_internal_semantic_offline_contract task13_self_test_windows_release_contract task13_self_test_server_release_contract - task13_self_test_server_auth_hop_contract task13_self_test_source_contract printf 'Task 13 smoke safety contracts passed.\n' } @@ -1633,7 +1694,6 @@ task13_self_test_case() { semantic-offline) task13_self_test_internal_semantic_offline_contract ;; windows) task13_self_test_windows_release_contract ;; server) task13_self_test_server_release_contract ;; - server-auth) task13_self_test_server_auth_hop_contract ;; server-diagnostics) task13_self_test_server_workspace_diagnostics ;; *) task13_fail "unknown Task 13 self-test case: $1" ;; esac @@ -1708,6 +1768,12 @@ task13_initialize() { TASK13_OVERRIDE="$TASK13_TMP/compose.task13.yaml" TASK13_PI_AUTH="$TASK13_TMP/pi-auth.json" TASK13_SECRETS="$TASK13_TMP/thothii.secrets" + TASK13_AUTH_ROOT="$TASK13_TMP/auth" + TASK13_AUTH_PASSWORD_FILE="$TASK13_TMP/local-auth-password" + TASK13_AUTH_ADMIN=task13-admin + TASK13_AUTH_PASSWORD="task13-auth-$TASK13_RUN_ID" + TASK13_OIDC_CLIENT_SECRET="task13-oidc-client-$TASK13_RUN_ID" + TASK13_AUTHENTIK_API_TOKEN="task13-authentik-token-$TASK13_RUN_ID" TASK13_PI_MODELS="$TASK13_TMP/models.json" TASK13_PI_SETTINGS="$TASK13_TMP/pi-settings.json" TASK13_LLM_SERVER="$TASK13_TMP/fake-llm.mjs" @@ -1729,10 +1795,11 @@ task13_initialize() { TASK13_SESSION_CA="$TASK13_TMP/session-ca.pem" TASK13_SESSION_PASSWORD="task13-runtime-$TASK13_RUN_ID" TASK13_SESSION_MIGRATOR_PASSWORD="task13-migrator-$TASK13_RUN_ID" + TASK13_FRONTEND_PORT="" } task13_require_tools() { - for command in bash git docker curl sed awk grep rg sort; do + for command in bash git docker curl node sed awk grep rg sort; do command -v "$command" >/dev/null 2>&1 || task13_fail "$command is required" done task13_run_logged "Docker daemon readiness" docker info @@ -1745,10 +1812,13 @@ task13_smoke_main() { [[ "$mode" == full || "$mode" == update ]] || task13_fail "unknown Task 13 smoke mode: $mode" task13_initialize task13_require_tools + TASK13_FRONTEND_PORT="$(node -e 'const net=require("node:net"); const server=net.createServer(); server.listen(0,"127.0.0.1",()=>{process.stdout.write(String(server.address().port)); server.close()})')" + [[ "$TASK13_FRONTEND_PORT" =~ ^[1-9][0-9]*$ ]] || task13_fail "could not reserve a loopback frontend port" task13_write_fixture_files task13_write_environment /fixtures/remote.git task13_seed_registry task13_build_tht + task13_configure_local_authentication task13_start_stack task13_assert_project_ownership task13_assert_built_image_ownership @@ -1767,6 +1837,8 @@ task13_server_smoke_main() { task13_require_tools task13_write_server_fixture_files task13_seed_registry + task13_build_tht + task13_configure_server_oidc_authentication task13_start_server_stack task13_assert_project_ownership task13_assert_built_image_ownership diff --git a/tools/tht/cmd/tht/main.go b/tools/tht/cmd/tht/main.go index 4f03019e..fa79b1ff 100644 --- a/tools/tht/cmd/tht/main.go +++ b/tools/tht/cmd/tht/main.go @@ -364,6 +364,26 @@ func parseSetupArgs(args []string) (setup.Request, error) { target = &request.Answers.GitSSHKeyFile case "--git-known-hosts-file": target = &request.Answers.GitKnownHostsFile + case "--auth-mode": + target = &request.Answers.AuthMode + case "--auth-public-url": + target = &request.Answers.AuthPublicURL + case "--auth-admin-user": + target = &request.Answers.AuthAdminUser + case "--auth-admin-display-name": + target = &request.Answers.AuthAdminDisplayName + case "--auth-password-file": + target = &request.Answers.AuthPasswordFile + case "--auth-issuer": + target = &request.Answers.AuthIssuer + case "--auth-client-id": + target = &request.Answers.AuthClientID + case "--auth-authentik-base-url": + target = &request.Answers.AuthAuthentikBaseURL + case "--auth-user-group": + target = &request.Answers.AuthUserGroup + case "--auth-admin-group": + target = &request.Answers.AuthAdminGroup default: return setup.Request{}, fmt.Errorf("unknown setup option %q", flag) } diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index dc18ef6b..c5099280 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -43,6 +43,22 @@ func TestBackupCommandParsesSafeTransactionalOptions(t *testing.T) { } } +func TestSetupArgumentsRequireCompleteNonInteractiveAuthenticationInputs(t *testing.T) { + request, err := parseSetupArgs([]string{ + "--non-interactive", "--auth-mode", "local", "--auth-public-url", "http://127.0.0.1:8080", + "--auth-admin-user", "admin", "--auth-admin-display-name", "Initial Admin", "--auth-password-file", "/protected/password", + }) + if err != nil { + t.Fatal(err) + } + if request.Answers.AuthMode != "local" || request.Answers.AuthPasswordFile != "/protected/password" { + t.Fatalf("setup request = %#v", request) + } + if _, err := parseSetupArgs([]string{"--auth-mode", "local", "--auth-mode", "oidc"}); err == nil { + t.Fatal("duplicate --auth-mode was accepted") + } +} + func TestBackupCommandDispatchesWithoutDockerAndPrintsCustodyWarning(t *testing.T) { installation := config.Installation{Path: "/tmp/thothii-installation.yaml"} var received backup.CreateRequest @@ -1469,7 +1485,7 @@ case " $* " in if [ -n "${THT_FAKE_CONFIG:-}" ]; then printf '%s\n' "$THT_FAKE_CONFIG" else - printf '%s\n' '{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}' + printf '%s\n' '{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}' fi ;; *" run --rm --no-deps --no-TTY session-migrate "*) if [ "${THT_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then diff --git a/tools/tht/internal/backup/create.go b/tools/tht/internal/backup/create.go index 1c22f8d8..080e6443 100644 --- a/tools/tht/internal/backup/create.go +++ b/tools/tht/internal/backup/create.go @@ -21,7 +21,9 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/lifecycle" + "github.com/aritmolab/thothii/tools/tht/internal/safeio" "github.com/aritmolab/thothii/tools/tht/internal/service" + "gopkg.in/yaml.v3" ) var ( @@ -130,6 +132,10 @@ func createWithDependencies(ctx context.Context, installation config.Installatio if err != nil { return Result{}, fmt.Errorf("installation external secret references could not be read: %w", err) } + authenticationPaths, err := authenticationConfigFiles(installation) + if err != nil { + return Result{}, err + } revision, err := dependencies.revision(ctx, installation.ProjectDirectory) if err != nil { return Result{}, err @@ -208,12 +214,12 @@ func createWithDependencies(ctx context.Context, installation config.Installatio InstallationID: installationID, CreatedAt: dependencies.now().UTC(), SourceRevision: revision, - IncludesSecrets: request.IncludeSecrets && len(secretPaths) > 0, + IncludesSecrets: request.IncludeSecrets && len(secretPaths)+len(authenticationPaths) > 0, ComposeProject: installation.ProjectName(), Images: images, Volumes: volumes, } - if err := writeArchive(ctx, output, reservation, installation, request, secretPaths, manifest, volumes, dependencies); err != nil { + if err := writeArchive(ctx, output, reservation, installation, request, secretPaths, authenticationPaths, manifest, volumes, dependencies); err != nil { return Result{}, err } published = true @@ -228,8 +234,8 @@ func createWithDependencies(ctx context.Context, installation config.Installatio maintenanceActive = false } result = Result{Path: output} - if request.IncludeSecrets { - result.Warning = "The archive contains external secret files. Protect its custody and access." + if manifest.IncludesSecrets { + result.Warning = "The archive contains external secret files, including authentication configuration. Protect its custody and access." } return result, nil } @@ -631,7 +637,7 @@ func runCompose(ctx context.Context, installation config.Installation, runner ar return nil } -func writeArchive(ctx context.Context, output string, reservation *archiveReservation, installation config.Installation, request CreateRequest, secretPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) { +func writeArchive(ctx context.Context, output string, reservation *archiveReservation, installation config.Installation, request CreateRequest, secretPaths, authenticationPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) { directory := filepath.Dir(output) temporary, err := os.CreateTemp(directory, ".tht-backup-*.tmp") if err != nil { @@ -732,6 +738,23 @@ func writeArchive(ctx context.Context, output string, reservation *archiveReserv SourcePath: source, SHA256: "sha256:" + hex.EncodeToString(hash.Sum(nil)), Size: size, Sensitive: true, }) } + for index, source := range authenticationPaths { + name := fmt.Sprintf("authentication-secrets/%03d-%s", index, filepath.Base(source)) + if request.IncludeSecrets { + if err := addFile(name, "authentication-configuration", true, source); err != nil { + return err + } + entry := &manifest.Entries[len(manifest.Entries)-1] + entry.Kind, entry.SourcePath, entry.Sensitive = EntryExternalSecret, source, true + continue + } + if filepath.Base(source) != "auth.yaml" { + continue + } + if err := addAuthenticationReference(&manifest, name, source); err != nil { + return err + } + } for _, volume := range volumes { headerName := "volumes/" + volume.LogicalName + ".tar" header := &zip.FileHeader{Name: headerName, Method: zip.Deflate} @@ -804,6 +827,51 @@ func configurationInputs(installation config.Installation) []configurationInput return inputs } +const maxAuthenticationConfigurationBytes = 1 << 20 + +func authenticationConfigFiles(installation config.Installation) ([]string, error) { + directory := installation.AuthenticationDirectory() + if directory == "" { + return nil, nil + } + if err := safeio.ValidatePrivateDirectory(directory); err != nil { + return nil, errors.New("authentication configuration directory is unavailable or unsafe") + } + authPath := filepath.Join(directory, "auth.yaml") + contents, err := safeio.ReadCanonicalRegular(authPath, maxAuthenticationConfigurationBytes) + if err != nil { + return nil, errors.New("authentication configuration is unavailable or unsafe") + } + var configuration struct { + Mode string `yaml:"mode"` + } + if err := yaml.Unmarshal(contents, &configuration); err != nil || (configuration.Mode != "local" && configuration.Mode != "oidc") { + return nil, errors.New("authentication configuration is unavailable or invalid") + } + paths := []string{authPath} + if configuration.Mode == "local" { + usersPath := filepath.Join(directory, "users.yaml") + if _, err := safeio.ReadCanonicalRegular(usersPath, maxAuthenticationConfigurationBytes); err != nil { + return nil, errors.New("authentication user registry is unavailable or unsafe") + } + paths = append(paths, usersPath) + } + return paths, nil +} + +func addAuthenticationReference(manifest *Manifest, name, source string) error { + contents, err := safeio.ReadCanonicalRegular(source, maxAuthenticationConfigurationBytes) + if err != nil { + return errors.New("authentication configuration is unavailable or unsafe") + } + digest := sha256.Sum256(contents) + manifest.Entries = append(manifest.Entries, Entry{ + Path: name, Kind: EntrySecretReference, Owner: "authentication-configuration", SourcePath: source, + SHA256: "sha256:" + hex.EncodeToString(digest[:]), Size: int64(len(contents)), Sensitive: true, + }) + return nil +} + func volumeArchiveCommand(volume string) []string { return []string{"run", "--rm", "--network", "none", "--mount", "type=volume,src=" + volume + ",dst=/source,readonly", helperImage, "tar", "--numeric-owner", "-C", "/source", "-cf", "-", "."} } @@ -837,6 +905,10 @@ func archivePreservationRoots(installation config.Installation, secretPaths []st if err != nil || !info.IsDir() { return errors.New("server preservation root is unavailable") } + authStateRoot := "" + if variable == "THT_DATA_ROOT" { + authStateRoot = filepath.Join(root, "auth") + } err = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -847,6 +919,12 @@ func archivePreservationRoots(installation config.Installation, secretPaths []st } return nil } + if authStateRoot != "" && path == authStateRoot { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } if entry.Type()&os.ModeSymlink != 0 { return errors.New("server preservation root contains a symlink") } diff --git a/tools/tht/internal/backup/create_test.go b/tools/tht/internal/backup/create_test.go index 0e0ebcad..5ea50a3e 100644 --- a/tools/tht/internal/backup/create_test.go +++ b/tools/tht/internal/backup/create_test.go @@ -70,6 +70,75 @@ func TestCreateWritesManifestLastWithConfigurationMetadataAndSevenVolumes(t *tes } } +func TestCreateReferencesAuthFilesByDefaultAndArchivesThemOnlyWithSecretCustody(t *testing.T) { + fixture := newBackupFixture(t, "local") + authDirectory := filepath.Join(filepath.Dir(fixture.installation.Path), "auth") + if err := os.Mkdir(authDirectory, 0o700); err != nil { + t.Fatal(err) + } + authPath := filepath.Join(authDirectory, "auth.yaml") + usersPath := filepath.Join(authDirectory, "users.yaml") + if err := os.WriteFile(authPath, []byte("version: 1\nmode: local\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(usersPath, []byte("users:\n - passwordHash: must-not-be-archived-by-default\n"), 0o600); err != nil { + t.Fatal(err) + } + fixture.installation.Authentication.ConfigDirectory = authDirectory + + defaultOutput := filepath.Join(t.TempDir(), "default.zip") + defaultResult, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: defaultOutput}, testDependencies(t, newBackupRunner(fixture.installation, false))) + if err != nil { + t.Fatal(err) + } + if defaultResult.Warning != "" { + t.Fatalf("default backup warning = %q, want no custody warning", defaultResult.Warning) + } + defaultArchive := readFixtureArchive(t, defaultOutput) + defaultBytes := bytes.Join(mapValues(defaultArchive.files), nil) + for _, value := range []string{"mode: local", "must-not-be-archived-by-default"} { + if bytes.Contains(defaultBytes, []byte(value)) { + t.Fatalf("default backup contains authentication content %q", value) + } + } + if !manifestHasReference(defaultArchive.manifest, authPath) || manifestHasReference(defaultArchive.manifest, usersPath) { + t.Fatalf("default backup did not record only the auth.yaml configuration path: %#v", defaultArchive.manifest.Entries) + } + + secretOutput := filepath.Join(t.TempDir(), "with-auth-secrets.zip") + secretResult, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: secretOutput, IncludeSecrets: true, Confirm: true}, testDependencies(t, newBackupRunner(fixture.installation, false))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(secretResult.Warning, "custody") { + t.Fatalf("secret backup warning = %q, want custody guidance", secretResult.Warning) + } + secretArchive := readFixtureArchive(t, secretOutput) + for _, path := range []string{authPath, usersPath} { + if !manifestHasArchivedSecret(secretArchive.manifest, path) { + t.Fatalf("secret backup did not archive authentication file %q", path) + } + } +} + +func manifestHasReference(manifest Manifest, sourcePath string) bool { + for _, entry := range manifest.Entries { + if entry.Kind == EntrySecretReference && entry.SourcePath == sourcePath && !entry.Archived { + return true + } + } + return false +} + +func manifestHasArchivedSecret(manifest Manifest, sourcePath string) bool { + for _, entry := range manifest.Entries { + if entry.Kind == EntryExternalSecret && entry.SourcePath == sourcePath && entry.Archived && entry.Sensitive { + return true + } + } + return false +} + func TestCreateRestartsAndVerifiesAnInstallationThatWasRunning(t *testing.T) { fixture := newBackupFixture(t, "local") runner := newBackupRunner(fixture.installation, true) diff --git a/tools/tht/internal/backup/restore.go b/tools/tht/internal/backup/restore.go index ce045bbf..609d8887 100644 --- a/tools/tht/internal/backup/restore.go +++ b/tools/tht/internal/backup/restore.go @@ -27,18 +27,19 @@ type RestoreResult struct { Verified bool } -type restoreLock interface { Release() error } +type restoreLock interface{ Release() error } type restoreVerify func(context.Context, config.Installation, archiveRunner) error type restoreDependencies struct { - preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error) - checkpoint func(context.Context, config.Installation, CreateRequest) (Result, error) - acquireLock func(config.Installation) (restoreLock, error) - runner archiveRunner - sleep func(duration time.Duration) - restoreFile func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error - restoreVolume func(context.Context, config.Installation, VolumeMetadata, io.Reader) error - verify map[string]restoreVerify + preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error) + checkpoint func(context.Context, config.Installation, CreateRequest) (Result, error) + acquireLock func(config.Installation) (restoreLock, error) + runner archiveRunner + sleep func(duration time.Duration) + restoreFile func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error + restoreVolume func(context.Context, config.Installation, VolumeMetadata, io.Reader) error + resetAuthenticationState func(context.Context, config.Installation, archiveRunner) error + verify map[string]restoreVerify } // Restore runs the host transaction. Concrete host dependencies are intentionally kept outside @@ -48,60 +49,126 @@ func Restore(ctx context.Context, installation config.Installation, request Rest } func restoreWithDependencies(ctx context.Context, installation config.Installation, request RestoreRequest, deps restoreDependencies) (result RestoreResult, resultErr error) { - if !request.Confirm { return RestoreResult{}, ErrRestoreConfirmationRequired } - if request.Archive == "" { return RestoreResult{}, errors.New("restore archive is required") } - if deps.preflight == nil || deps.checkpoint == nil || deps.acquireLock == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.verify == nil { + if !request.Confirm { + return RestoreResult{}, ErrRestoreConfirmationRequired + } + if request.Archive == "" { + return RestoreResult{}, errors.New("restore archive is required") + } + if deps.preflight == nil || deps.checkpoint == nil || deps.acquireLock == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.resetAuthenticationState == nil || deps.verify == nil { return RestoreResult{}, errors.New("restore dependencies are incomplete") } preflight, err := deps.preflight(ctx, installation, PreflightRequest{Archive: request.Archive, Confirm: true, AllowExternalSecrets: true}) - if err != nil { return RestoreResult{}, err } + if err != nil { + return RestoreResult{}, err + } defer preflight.CloseArchive() archive, err := preflight.RevalidateArchive() - if err != nil { return RestoreResult{}, err } + if err != nil { + return RestoreResult{}, err + } checkpoint, err := deps.checkpoint(ctx, installation, CreateRequest{}) - if err != nil { return RestoreResult{}, fmt.Errorf("create recovery checkpoint: %w", err) } + if err != nil { + return RestoreResult{}, fmt.Errorf("create recovery checkpoint: %w", err) + } result.Checkpoint = checkpoint.Path lock, err := deps.acquireLock(installation) - if err != nil { return result, err } - defer func() { if releaseErr := lock.Release(); releaseErr != nil && resultErr == nil { resultErr = releaseErr } }() + if err != nil { + return result, err + } + defer func() { + if releaseErr := lock.Release(); releaseErr != nil && resultErr == nil { + resultErr = releaseErr + } + }() wasRunning, err := installationRunning(ctx, installation, deps.runner) - if err != nil { return result, err } + if err != nil { + return result, err + } mutated := false defer func() { - if resultErr != nil && mutated { _ = runCompose(context.Background(), installation, deps.runner, "stop") } + if resultErr != nil && mutated { + _ = runCompose(context.Background(), installation, deps.runner, "stop") + } }() if wasRunning { - if err := maintenance(ctx, installation, deps.runner, true); err != nil { return result, err } - if err := waitForNoActiveSessions(ctx, installation, deps.runner, request.Drain, deps.sleep); err != nil { return result, err } - if err := runCompose(ctx, installation, deps.runner, "stop"); err != nil { return result, err } + if err := maintenance(ctx, installation, deps.runner, true); err != nil { + return result, err + } + if err := waitForNoActiveSessions(ctx, installation, deps.runner, request.Drain, deps.sleep); err != nil { + return result, err + } + if err := runCompose(ctx, installation, deps.runner, "stop"); err != nil { + return result, err + } } reader, err := zip.NewReader(archive, preflight.ArchiveSize) - if err != nil { return result, fmt.Errorf("read verified restore archive: %w", err) } + if err != nil { + return result, fmt.Errorf("read verified restore archive: %w", err) + } members := make(map[string]*zip.File, len(reader.File)) - for _, member := range reader.File { members[member.Name] = member } + for _, member := range reader.File { + members[member.Name] = member + } for _, entry := range preflight.Entries { - if entry.Kind == EntryVolume { continue } + if entry.Kind == EntryVolume { + continue + } member := members[entry.Path] - if member == nil { return result, fmt.Errorf("verified archive is missing %q", entry.Path) } + if member == nil { + return result, fmt.Errorf("verified archive is missing %q", entry.Path) + } stream, openErr := member.Open() - if openErr != nil { return result, fmt.Errorf("open verified archive member %q: %w", entry.Path, openErr) } + if openErr != nil { + return result, fmt.Errorf("open verified archive member %q: %w", entry.Path, openErr) + } mutated = true restoreErr := deps.restoreFile(ctx, installation, entry, stream) closeErr := stream.Close() - if restoreErr != nil { return result, restoreErr } - if closeErr != nil { return result, closeErr } + if restoreErr != nil { + return result, restoreErr + } + if closeErr != nil { + return result, closeErr + } + } + if err := deps.resetAuthenticationState(ctx, installation, deps.runner); err != nil { + return result, fmt.Errorf("reset authentication state: %w", err) } if wasRunning { - if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil { return result, err } + if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil { + return result, err + } result.Restarted = true } for _, name := range []string{"health", "doctor", "pi", "workspace"} { check := deps.verify[name] - if check == nil { return result, fmt.Errorf("restore verification %q is unavailable", name) } - if err := check(ctx, installation, deps.runner); err != nil { return result, fmt.Errorf("restore verification %s: %w", name, err) } + if check == nil { + return result, fmt.Errorf("restore verification %q is unavailable", name) + } + if err := check(ctx, installation, deps.runner); err != nil { + return result, fmt.Errorf("restore verification %s: %w", name, err) + } } result.Verified = true return result, nil } + +// resetAuthenticationState clears browser sessions and pending OIDC transactions without touching +// installation-global auth.yaml or users.yaml. The command runs as the unprivileged core user so +// the recreated state root is private to the service on both the local volume and server /data bind. +func resetAuthenticationState(ctx context.Context, installation config.Installation, runner archiveRunner) error { + result, err := runner.Run(ctx, installation.ComposeArgs( + "run", "--rm", "--no-deps", "--no-TTY", "--entrypoint", "sh", "core", "-ceu", + "rm -rf /data/auth && install -d -m 0700 /data/auth /data/auth/sessions /data/auth/oidc", + ), nil) + if err != nil { + return dockerError("reset authentication state", result, err) + } + if result.ExitCode != 0 { + return dockerError("reset authentication state", result, errors.New("Compose returned a nonzero exit status")) + } + return nil +} diff --git a/tools/tht/internal/backup/restore_test.go b/tools/tht/internal/backup/restore_test.go index ee85b11d..4600f4ed 100644 --- a/tools/tht/internal/backup/restore_test.go +++ b/tools/tht/internal/backup/restore_test.go @@ -59,6 +59,58 @@ func TestRestoreStoppedInstallationRunsCheckpointRestoreAndVerification(t *testi } } +func TestRestoreResetsAuthenticationStateBeforeRestart(t *testing.T) { + installation := preflightTestInstallation(t) + archive := restoreArchive(t) + runner := newBackupRunner(installation, true) + deps := restoreTestDependencies(t, runner) + var events []string + deps.restoreFile = func(_ context.Context, _ config.Installation, entry ArchiveEntryMetadata, _ io.Reader) error { + events = append(events, "file:"+entry.Path) + return nil + } + deps.resetAuthenticationState = func(context.Context, config.Installation, archiveRunner) error { + events = append(events, "reset-auth-state") + return nil + } + for _, name := range []string{"health", "doctor", "pi", "workspace"} { + name := name + deps.verify[name] = func(context.Context, config.Installation, archiveRunner) error { + events = append(events, name) + return nil + } + } + + result, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps) + if err != nil { + t.Fatal(err) + } + if !result.Restarted || !result.Verified { + t.Fatalf("restore result = %#v", result) + } + if got, want := events, []string{"file:configuration/operator.env", "reset-auth-state", "health", "doctor", "pi", "workspace"}; !equalStrings(got, want) { + t.Fatalf("restore events = %v, want %v", got, want) + } +} + +func TestResetAuthenticationStateCreatesOnlyPrivateEmptyStateDirectories(t *testing.T) { + installation := preflightTestInstallation(t) + runner := &authenticationStateResetRunner{} + + if err := resetAuthenticationState(context.Background(), installation, runner); err != nil { + t.Fatal(err) + } + joined := strings.Join(runner.args, "\x00") + for _, required := range []string{ + "run", "--rm", "--no-deps", "--no-TTY", "--entrypoint", "sh", "core", "-ceu", + "rm -rf /data/auth && install -d -m 0700 /data/auth /data/auth/sessions /data/auth/oidc", + } { + if !strings.Contains(joined, required) { + t.Fatalf("authentication state reset command omits %q: %#v", required, runner.args) + } + } +} + func TestRestorePreflightFailureDoesNotMutateTarget(t *testing.T) { installation := preflightTestInstallation(t) runner := newBackupRunner(installation, true) @@ -213,6 +265,19 @@ type fakeRestoreLock struct { release func() } +type authenticationStateResetRunner struct{ args []string } + +func (runner *authenticationStateResetRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { + runner.args = append([]string(nil), args...) + return compose.Result{}, nil +} + +func (runner *authenticationStateResetRunner) Stream(context.Context, []string, io.Reader, io.Writer) (compose.Result, error) { + return compose.Result{}, errors.New("authentication state reset must not stream a volume archive") +} + +func (*authenticationStateResetRunner) SessionInventoryScope() string { return "mine" } + func (lock fakeRestoreLock) Release() error { if lock.release != nil { lock.release() @@ -248,6 +313,9 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen restoreVolume: func(context.Context, config.Installation, VolumeMetadata, io.Reader) error { return nil }, + resetAuthenticationState: func(context.Context, config.Installation, archiveRunner) error { + return nil + }, verify: map[string]restoreVerify{ "health": func(context.Context, config.Installation, archiveRunner) error { return nil }, "doctor": func(context.Context, config.Installation, archiveRunner) error { return nil }, diff --git a/tools/tht/internal/doctor/report.go b/tools/tht/internal/doctor/report.go index 574870a6..c492eba2 100644 --- a/tools/tht/internal/doctor/report.go +++ b/tools/tht/internal/doctor/report.go @@ -353,7 +353,7 @@ func filePermissions(installation config.Installation) error { return nil } -// ValidateVolumes checks the seven persistent volumes required by a ThothII installation. +// ValidateVolumes checks the eight persistent volumes required by a local ThothII installation. func ValidateVolumes(rendered string) error { var document struct { Volumes map[string]json.RawMessage `json:"volumes"` @@ -361,7 +361,7 @@ func ValidateVolumes(rendered string) error { if err := json.Unmarshal([]byte(rendered), &document); err != nil { return errors.New("Compose returned invalid rendered configuration") } - for _, name := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"} { + for _, name := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models", "auth-state"} { if _, exists := document.Volumes[name]; !exists { return fmt.Errorf("rendered Compose configuration is missing required volume %s", name) } diff --git a/tools/tht/internal/doctor/report_test.go b/tools/tht/internal/doctor/report_test.go index e255b693..1d844d04 100644 --- a/tools/tht/internal/doctor/report_test.go +++ b/tools/tht/internal/doctor/report_test.go @@ -27,6 +27,17 @@ func TestRunReportsUnavailableDockerWithoutReturningAnExecutionError(t *testing. } } +func TestValidateVolumesRequiresAuthState(t *testing.T) { + legacy := `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}}}` + if err := ValidateVolumes(legacy); err == nil || !strings.Contains(err.Error(), "auth-state") { + t.Fatalf("ValidateVolumes() error = %v, want missing auth-state", err) + } + withAuthState := `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}}}` + if err := ValidateVolumes(withAuthState); err != nil { + t.Fatalf("ValidateVolumes() error = %v, want complete volume set", err) + } +} + // Catches Docker availability short-circuiting a host file-permission failure. func TestRunChecksUnsafeFilesEvenWhenDockerIsUnavailable(t *testing.T) { installation := doctorInstallation(t, "") @@ -327,7 +338,7 @@ func assertChecklist(t *testing.T, report Report, want []string) { } } -const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}` +const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}` const healthyServices = `[ {"Service":"core","State":"running","Health":"healthy"}, diff --git a/tools/tht/internal/setup/files_test.go b/tools/tht/internal/setup/files_test.go index 75bc3e1e..6d1c43f4 100644 --- a/tools/tht/internal/setup/files_test.go +++ b/tools/tht/internal/setup/files_test.go @@ -65,6 +65,9 @@ func TestEnsureFilesCreatesDiscoverableConfigurationInProjectWithSpaces(t *testi if err != nil { t.Fatal(err) } + if !strings.Contains(string(environment), "THT_AUTH_CONFIG_ROOT=") { + t.Fatalf("generated environment does not declare the authentication config root: %s", environment) + } for _, secretValue := range []string{"super-secret-value", "pi-secret-value", "private-key-value"} { if bytes.Contains(descriptor, []byte(secretValue)) || bytes.Contains(environment, []byte(secretValue)) { t.Fatalf("generated configuration contains a secret value %q", secretValue) diff --git a/tools/tht/internal/setup/request.go b/tools/tht/internal/setup/request.go index d6156f5d..4099c83c 100644 --- a/tools/tht/internal/setup/request.go +++ b/tools/tht/internal/setup/request.go @@ -26,6 +26,16 @@ type Answers struct { GitCAFile string GitSSHKeyFile string GitKnownHostsFile string + AuthMode string + AuthPublicURL string + AuthAdminUser string + AuthAdminDisplayName string + AuthPasswordFile string + AuthIssuer string + AuthClientID string + AuthAuthentikBaseURL string + AuthUserGroup string + AuthAdminGroup string CreateSecretTemplates bool } diff --git a/tools/tht/internal/setup/run.go b/tools/tht/internal/setup/run.go index 28edde3b..f5f1921e 100644 --- a/tools/tht/internal/setup/run.go +++ b/tools/tht/internal/setup/run.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/doctor" @@ -53,6 +54,9 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R return Result{}, fmt.Errorf("setup generated configuration is invalid: %w", err) } result := Result{DescriptorPath: files.DescriptorPath, ProjectName: installation.ProjectName(), Configured: true} + if err := configureAuthentication(ctx, installation, request, input, output); err != nil { + return Result{}, err + } if err := runCompose(ctx, runner, installation, "config", "--quiet"); err != nil { return Result{}, fmt.Errorf("setup Compose configuration: %w", err) } @@ -78,6 +82,76 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R return result, nil } +func configureAuthentication(ctx context.Context, installation config.Installation, request Request, input io.Reader, output io.Writer) error { + directory := installation.AuthenticationDirectory() + if _, _, err := authconfig.Load(directory); err == nil { + return nil + } + if _, err := os.Lstat(filepath.Join(directory, "auth.yaml")); !errors.Is(err, os.ErrNotExist) { + return errors.New("setup authentication configuration is invalid") + } + args, err := authenticationConfigureArgs(request) + if err != nil { + return err + } + if exitCode := authconfig.Run(ctx, installation, args, input, io.Discard, output); exitCode != 0 { + return errors.New("setup authentication configuration failed") + } + if _, _, err := authconfig.Load(directory); err != nil { + return errors.New("setup authentication configuration is invalid") + } + return nil +} + +func authenticationConfigureArgs(request Request) ([]string, error) { + answers := request.Answers + mode := answers.AuthMode + if mode == "" && !request.NonInteractive { + mode = "local" + } + if mode != "local" && mode != "oidc" { + return nil, errors.New("setup requires --auth-mode local or oidc") + } + if mode == "local" && request.NonInteractive && (answers.AuthAdminUser == "" || answers.AuthAdminDisplayName == "" || answers.AuthPasswordFile == "") { + return nil, errors.New("non-interactive local authentication requires --auth-admin-user, --auth-admin-display-name, and --auth-password-file") + } + publicURL := answers.AuthPublicURL + if publicURL == "" && !request.NonInteractive && mode == "local" { + publicURL = "http://127.0.0.1:8080" + } + if publicURL == "" { + return nil, errors.New("setup requires --auth-public-url") + } + args := []string{"configure", "--mode", mode, "--public-url", publicURL} + if mode == "local" { + if answers.AuthAdminUser != "" { + args = append(args, "--admin-user", answers.AuthAdminUser) + } + if answers.AuthAdminDisplayName != "" { + args = append(args, "--admin-display-name", answers.AuthAdminDisplayName) + } + if answers.AuthPasswordFile != "" { + args = append(args, "--password-file", answers.AuthPasswordFile) + } + return args, nil + } + for _, option := range []struct { + name, value string + }{ + {"--issuer", answers.AuthIssuer}, + {"--client-id", answers.AuthClientID}, + {"--authentik-base-url", answers.AuthAuthentikBaseURL}, + {"--user-group", answers.AuthUserGroup}, + {"--admin-group", answers.AuthAdminGroup}, + } { + if option.value == "" { + return nil, errors.New("OIDC authentication requires complete provider and group options") + } + args = append(args, option.name, option.value) + } + return args, nil +} + func checkHost(ctx context.Context, runner compose.Runner, root string) error { checks := []struct { name string diff --git a/tools/tht/internal/setup/run_test.go b/tools/tht/internal/setup/run_test.go index 6410b2a5..d5a81279 100644 --- a/tools/tht/internal/setup/run_test.go +++ b/tools/tht/internal/setup/run_test.go @@ -11,7 +11,9 @@ import ( "testing" "time" + "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/compose" + "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/doctor" ) @@ -65,6 +67,55 @@ func TestRunConfigureOnlyStopsAfterRenderedConfiguration(t *testing.T) { } } +func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *testing.T) { + projectRoot, request := setupRunFixture(t, true) + passwordFile := filepath.Join(projectRoot, "initial-admin-password") + if err := os.WriteFile(passwordFile, []byte("correct horse battery staple"), 0o600); err != nil { + t.Fatal(err) + } + request.NonInteractive = true + request.Answers.AuthMode = "local" + request.Answers.AuthPublicURL = "http://127.0.0.1:8080" + request.Answers.AuthAdminUser = "admin" + request.Answers.AuthAdminDisplayName = "Initial Admin" + request.Answers.AuthPasswordFile = passwordFile + runner := &setupRunner{} + + if _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard); err != nil { + t.Fatal(err) + } + installationPath := filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml") + installation, err := config.Load(installationPath) + if err != nil { + t.Fatal(err) + } + configuration, registry, err := authconfig.Load(installation.AuthenticationDirectory()) + if err != nil { + t.Fatalf("setup did not create a statically valid authentication configuration: %v", err) + } + if configuration.Mode != "local" || len(registry.Users) != 1 || registry.Users[0].Username != "admin" { + t.Fatalf("authentication configuration = %#v registry = %#v", configuration, registry) + } + assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config") +} + +func TestRunRejectsIncompleteNonInteractiveLocalAuthenticationBeforeComposeRender(t *testing.T) { + _, request := setupRunFixture(t, true) + request.NonInteractive = true + request.Answers.AuthMode = "local" + request.Answers.AuthPublicURL = "" + request.Answers.AuthAdminUser = "" + request.Answers.AuthAdminDisplayName = "" + request.Answers.AuthPasswordFile = "" + runner := &setupRunner{} + + _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) + if err == nil || !strings.Contains(err.Error(), "non-interactive local authentication") { + t.Fatalf("Run() error = %v, want non-interactive local authentication guidance", err) + } + assertSetupStages(t, runner, "docker engine", "docker compose", "architecture") +} + func TestRunPropagatesPreflightFailureBeforeWritingConfiguration(t *testing.T) { projectRoot, request := setupRunFixture(t, false) runner := &setupRunner{failureAt: "docker engine"} @@ -137,7 +188,7 @@ func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) { } func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) { - all := []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"} + all := []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models", "auth-state"} for _, missing := range all { t.Run("missing "+missing, func(t *testing.T) { volumes := make([]string, 0, len(all)-1) @@ -301,7 +352,7 @@ func setupStage(args []string) (string, compose.Result) { } } -const renderedSetupConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}` +const renderedSetupConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}` func renderedConfigForVolumes(volumes ...string) string { entries := make([]string, 0, len(volumes)) @@ -338,12 +389,18 @@ func setupRunFixture(t *testing.T, configureOnly bool) (string, Request) { if err := os.MkdirAll(secrets, 0o700); err != nil { t.Fatal(err) } + passwordFile := filepath.Join(secrets, "initial-admin-password") + if err := os.WriteFile(passwordFile, []byte("fixture authentication password"), 0o600); err != nil { + t.Fatal(err) + } return root, Request{ ProjectRoot: root, InstallationID: "ci", Profile: "local", ConfigureOnly: configureOnly, NonInteractive: true, Answers: Answers{ WorkspaceRemote: "https://git.example.invalid/thothii-workspaces.git", WorkspaceBranch: "main", WorkspaceAccess: "https", SecretsFile: filepath.Join(secrets, "thothii.secrets"), PiAuthFile: filepath.Join(secrets, "pi-auth.json"), GitCredentialsFile: filepath.Join(secrets, "git-credentials"), GitCAFile: filepath.Join(secrets, "git-ca.pem"), + AuthMode: "local", AuthPublicURL: "http://127.0.0.1:8080", AuthAdminUser: "admin", + AuthAdminDisplayName: "Initial Admin", AuthPasswordFile: passwordFile, CreateSecretTemplates: true, }, }