feat(auth): integrate authentication with installation lifecycle

This commit is contained in:
2026-08-17 20:21:42 +02:00
parent 0a2c667231
commit 9558eaa508
30 changed files with 756 additions and 120 deletions
+5
View File
@@ -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:
-1
View File
@@ -1,7 +1,6 @@
services:
core:
environment:
AUTH_MODE: none
NODE_ENV: development
THT_WORKSPACE_INSTALLATION_ID: local
ports:
+6 -1
View File
@@ -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:
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1 -1
View File
@@ -9,6 +9,7 @@ THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=<abs>/deploy/psd/secrets/git-known-hosts
# App
THT_SECRETS_FILE=<abs>/deploy/psd/secrets/thothii.secrets
PI_AUTH_FILE=<abs>/deploy/psd/secrets/pi-auth.json
THT_AUTH_CONFIG_ROOT=<abs>/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
@@ -8,5 +8,7 @@ workspaceRepository:
remote: git@github.com:mptyl/tht-workspace-psd.git
branch: main
access: ssh
authentication:
configDirectory: "<abs>/projects/ThothII/deploy/psd/auth"
overrides:
- "<abs>/projects/ThothII/deploy/compose.git-ssh.yaml"
+4 -2
View File
@@ -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
+4
View File
@@ -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=
@@ -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"
@@ -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"
+15 -5
View File
@@ -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"
+21 -2
View File
@@ -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");
}
+17
View File
@@ -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"
+19 -3
View File
@@ -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}`);
+7
View File
@@ -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"
+133 -61
View File
@@ -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" <<EOF
{
@@ -293,6 +297,8 @@ services:
- $TASK13_PI_SETTINGS:/home/thoth/.pi/agent/settings.json:ro
- workspace-registry:/data/workspace-registry
- sessions:/data/sessions
- $TASK13_AUTH_ROOT:/run/thothii-auth:ro
- auth-state:/data/auth
- $TASK13_SESSION_RUNTIME_PASSWORD:/run/secrets/task13-runtime-password:ro
- $TASK13_REMOTE:/fixtures/remote.git:ro
frontend:
@@ -303,6 +309,8 @@ services:
labels:
io.thothii.task13.run: "$TASK13_RUN_ID"
qdrant:
# The normal local profile exposes a developer dashboard; the isolated smoke needs no host port.
ports: !reset []
labels:
io.thothii.task13.run: "$TASK13_RUN_ID"
embedding:
@@ -328,6 +336,9 @@ volumes:
sessions:
labels:
io.thothii.task13.run: "$TASK13_RUN_ID"
auth-state:
labels:
io.thothii.task13.run: "$TASK13_RUN_ID"
qdrant-data:
labels:
io.thothii.task13.run: "$TASK13_RUN_ID"
@@ -341,6 +352,8 @@ EOF
profile: local
projectDirectory: "$TASK13_ROOT"
envFile: "$TASK13_ENV_FILE"
authentication:
configDirectory: "$TASK13_AUTH_ROOT"
overrides:
- "$TASK13_OVERRIDE"
EOF
@@ -350,7 +363,8 @@ EOF
task13_write_server_fixture_files() {
local data_root pi_root registry_root remote_path workspace_path
printf '{}\n' >"$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" <<EOF
profile: server
projectDirectory: "$TASK13_ROOT"
envFile: "$TASK13_ENV_FILE"
authentication:
configDirectory: "$TASK13_AUTH_ROOT"
overrides:
- "$TASK13_ROOT/deploy/compose.session-server.yaml.example"
- "$TASK13_OVERRIDE"
EOF
chmod 0600 "$TASK13_INSTALLATION"
}
task13_workspace_metadata() {
@@ -596,6 +625,22 @@ task13_assert_rendered_contract() {
if grep -Eq 'THT_VEC_REST_URL|THT_VEC_WRITE_REST_URL|THT_OLLAMA_URL' "$rendered"; then
task13_fail "rendered Compose still exposes retired external semantic bindings"
fi
grep -Fq '/run/thothii-auth' "$rendered" || task13_fail "rendered Compose lacks the read-only auth configuration mount"
grep -Fq '/data/auth' "$rendered" || task13_fail "rendered Compose lacks authentication state storage"
}
task13_configure_local_authentication() {
task13_run_logged "configure local authentication" "$TASK13_THT" --installation "$TASK13_INSTALLATION" auth configure \
--mode local --public-url "http://127.0.0.1:$TASK13_FRONTEND_PORT" \
--admin-user "$TASK13_AUTH_ADMIN" --admin-display-name "Task 13 Administrator" \
--password-file "$TASK13_AUTH_PASSWORD_FILE"
}
task13_configure_server_oidc_authentication() {
task13_run_logged "configure fake server OIDC authentication" "$TASK13_THT" --installation "$TASK13_INSTALLATION" auth configure \
--mode oidc --public-url "https://task13.example.invalid" \
--issuer "https://task13-fake-oidc.invalid/application/o/task13/" --client-id task13-smoke-client \
--authentik-base-url "https://task13-fake-authentik.invalid" --user-group task13-users --admin-group task13-admins
}
task13_start_stack() {
@@ -641,6 +686,49 @@ task13_core_id() {
task13_compose ps -q core
}
task13_assert_maintenance_auth_isolation() {
task13_compose_logged "workspace maintenance auth isolation" \
--profile workspace-maintenance run --rm --no-deps --entrypoint sh workspace-maintenance -ceu \
'test ! -e /run/thothii-auth && test ! -e /data/auth'
}
task13_assert_local_auth_lifecycle() {
local frontend cookie_jar login_body me csrf unauthenticated
frontend="$(task13_frontend_address)"
cookie_jar="$TASK13_TMP/local-auth.cookies"
login_body="$TASK13_TMP/local-auth-login.json"
printf '{"username":"%s","password":"%s","remember":true}' \
"$TASK13_AUTH_ADMIN" "$TASK13_AUTH_PASSWORD" >"$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
+20
View File
@@ -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)
}
+17 -1
View File
@@ -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
+83 -5
View File
@@ -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")
}
+69
View File
@@ -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)
+99 -32
View File
@@ -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
}
+68
View File
@@ -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 },
+2 -2
View File
@@ -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)
}
+12 -1
View File
@@ -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"},
+3
View File
@@ -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)
+10
View File
@@ -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
}
+74
View File
@@ -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
+59 -2
View File
@@ -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,
},
}