docs: converge operator guidance on tht

This commit is contained in:
2026-08-19 16:12:11 +02:00
parent 32a17d83a9
commit 1184b6db16
29 changed files with 544 additions and 380 deletions
+4 -2
View File
@@ -22,6 +22,8 @@ docs=(
"$root/docs/install/psd-workspace-setup.md"
"$root/docs/install/reverse-proxy-caddy.md"
"$root/docs/install/reverse-proxy-nginx.md"
"$root/docs/contracts/tht-pi.md"
"$root/docs/contracts/workspace-preprocessing-cli.md"
"$root/docs/guida-utente.md"
"$root/docs/index.md"
"$root/README.md"
@@ -138,8 +140,8 @@ for relative, language, forbidden, required in [
raise SystemExit(f"auth docs smoke: {relative} omits scoped deprecated upstream auth")
PY
if rg -n -i --pcre2 '\bthothii-admin\b|\bthothctl\b[^\r\n]{0,256}\bauth\b' "$corpus"; then
echo "auth docs smoke: forbidden authentication CLI wording" >&2
if rg -n -i --pcre2 '\bthothii-admin\b|\bthothctl\b' "$corpus"; then
echo "auth docs smoke: forbidden obsolete host CLI wording" >&2
exit 1
fi
if rg -n -i --pcre2 -- '--password(?!-file)\b(?:[[:space:]]+|=)\S+' "$corpus"; then
+4 -2
View File
@@ -17,6 +17,8 @@ files=(
docs/install/psd-workspace-setup.md
docs/install/reverse-proxy-caddy.md
docs/install/reverse-proxy-nginx.md
docs/contracts/tht-pi.md
docs/contracts/workspace-preprocessing-cli.md
docs/guida-utente.md
docs/index.md
README.md
@@ -65,10 +67,10 @@ echo "auth docs positive fixture passed"
expect_rejected thothctl-intervening \
'Run thothctl --installation <descriptor> --json auth check.' \
'forbidden authentication CLI wording'
'forbidden obsolete host CLI wording'
expect_rejected alternate-admin \
'Run thothii-admin users list.' \
'forbidden authentication CLI wording'
'forbidden obsolete host CLI wording'
expect_rejected password-option \
'Run tht auth user add demo --password example-value.' \
'plaintext password option'
@@ -86,11 +86,11 @@ for required in \
exit 1
}
done
grep -Fq '"$THTCTL" --help' "$server_guide" || {
grep -Fq '"$THT_BIN" --help' "$server_guide" || {
echo "server guide lacks plain tht --help" >&2
exit 1
}
if grep -Fq '"$THTCTL" --installation "$INSTALLATION" --help' "$server_guide"; then
if grep -Fq '"$THT_BIN" --installation "$INSTALLATION" --help' "$server_guide"; then
echo "server guide still uses installation-scoped --help" >&2
exit 1
fi
@@ -106,7 +106,7 @@ for manual in "$root/docs/install/local-workspace-registry.md"; do
fi
done
grep -Fq 'THTCTL=/srv/thothii/operator/tht' \
grep -Fq 'THT_BIN=/srv/thothii/operator/tht' \
"$root/docs/install/server-workspace-registry.md" || {
echo "server installation manual does not use the installation-aware operator CLI" >&2
exit 1
@@ -645,6 +645,9 @@ expect_guide_rejected() {
if [[ "$validator" == verify_windows_line_endings_guide ]]; then
mkdir -p "$fixture_root/scripts"
cp "$root/scripts/verify-line-endings.sh" "$fixture_root/scripts/verify-line-endings.sh"
elif [[ "$validator" == verify_pi_management_guide ]]; then
mkdir -p "$fixture_root/docs/contracts"
cp "$root/docs/contracts/tht-pi.md" "$fixture_root/docs/contracts/tht-pi.md"
fi
node - "$fixture_root/$relative_path" "$mutation" <<'NODE'
const fs = require("fs");
@@ -910,11 +913,11 @@ expect_guide_rejected \
expect_guide_rejected \
"Nginx additional frontend bypass location" verify_reverse_proxy_nginx_guide \
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-additional-bypass \
"Nginx frontend upstream location bypasses complete authentication contract"
"Nginx direct OIDC mode contains an additional frontend bypass location"
expect_guide_rejected \
"Nginx frontend auth directives only in comments" verify_reverse_proxy_nginx_guide \
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-comment-only-auth \
"Nginx frontend upstream location bypasses complete authentication contract"
"Nginx direct OIDC mode contains an additional frontend bypass location"
expect_guide_rejected \
"Caddy identity without authentication" verify_reverse_proxy_caddy_guide \
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-auth \
+82 -62
View File
@@ -851,9 +851,9 @@ if (/\|\|\s*true|;\s*true\b/.test(updateShell)) throw new Error("POSIX source up
requirePattern("POSIX source update does not fail closed: source pull", updateShell,
/if ! git pull --ff-only; then abort_update/);
requirePattern("POSIX source update does not fail closed: installation status", updateShell,
/if ! INSTALLATION_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" status\)"; then/);
/if ! INSTALLATION_STATUS="\$\("\$THT_BIN" --installation "\$INSTALLATION" status\)"; then/);
requirePattern("POSIX source update does not fail closed: Pi status", updateShell,
/if ! RUNNING_PI_VERSION="\$\("\$THTCTL" --installation "\$INSTALLATION" pi status\)"; then/);
/if ! RUNNING_PI_VERSION="\$\("\$THT_BIN" --installation "\$INSTALLATION" pi status\)"; then/);
requirePattern("POSIX source update does not fail closed: local build", updateShell,
/if ! bash scripts\/build-local\.sh; then/);
requirePattern("POSIX source update does not fail closed: tht build", updateShell,
@@ -861,12 +861,12 @@ requirePattern("POSIX source update does not fail closed: tht build", updateShel
requirePattern("POSIX source update lacks the same-version/no-selector path", updateShell,
/if \[\[ "\$NEXT_PI_VERSION" == "\$RUNNING_PI_VERSION" \]\]; then[\s\S]*"\$USES_BASE_CORE" == true[\s\S]*TRANSACTIONAL_PI_UPDATE=false/);
for (const [label, pattern] of [
["installation start", /if ! "\$THTCTL" --installation "\$INSTALLATION" start; then/],
["installation start", /if ! "\$THT_BIN" --installation "\$INSTALLATION" start; then/],
["frontend health", /if ! curl --fail http:\/\/127\.0\.0\.1:8080\/health; then/],
["core health", /if ! curl --fail http:\/\/127\.0\.0\.1:8787\/health; then/],
["final status", /if ! FINAL_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" status\)"; then/],
["final Pi status", /if ! FINAL_PI_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" pi status\)"; then/],
["final doctor", /if ! "\$THTCTL" --installation "\$INSTALLATION" doctor; then/],
["final status", /if ! FINAL_STATUS="\$\("\$THT_BIN" --installation "\$INSTALLATION" status\)"; then/],
["final Pi status", /if ! FINAL_PI_STATUS="\$\("\$THT_BIN" --installation "\$INSTALLATION" pi status\)"; then/],
["final doctor", /if ! "\$THT_BIN" --installation "\$INSTALLATION" doctor; then/],
]) requirePattern(`POSIX source update does not fail closed: ${label}`, updateShell, pattern);
const provenance = updateShell.indexOf("printf 'Built source revision:");
if (provenance < updateShell.lastIndexOf("require_clean_source") ||
@@ -883,14 +883,14 @@ requireTokens("native PowerShell source update", updatePowerShell, [
]);
for (const [command, step] of [
["git pull --ff-only", "source pull"],
["$InstallationStatus = @(& $THTCTL --installation $INSTALLATION status)", "installation status"],
["$RunningPiStatus = (& $THTCTL --installation $INSTALLATION pi status)", "Pi status"],
["$InstallationStatus = @(& $THT_BIN --installation $INSTALLATION status)", "installation status"],
["$RunningPiStatus = (& $THT_BIN --installation $INSTALLATION pi status)", "Pi status"],
["powershell -ExecutionPolicy Bypass -File scripts/build-local.ps1", "local image build"],
["& \"C:\\Program Files\\Git\\bin\\bash.exe\" scripts/build-tht.sh", "tht build"],
["curl.exe --fail --silent --show-error http://127.0.0.1:8080/health", "frontend health check"],
["curl.exe --fail --silent --show-error http://127.0.0.1:8787/health", "core health check"],
["$FinalPiStatus = (& $THTCTL --installation $INSTALLATION pi status)", "final Pi status"],
["& $THTCTL --installation $INSTALLATION doctor", "final doctor"],
["$FinalPiStatus = (& $THT_BIN --installation $INSTALLATION pi status)", "final Pi status"],
["& $THT_BIN --installation $INSTALLATION doctor", "final doctor"],
]) {
const commandAt = updatePowerShell.indexOf(command);
const checkAt = updatePowerShell.indexOf(`Assert-NativeSuccess '${step}'`, commandAt);
@@ -963,7 +963,7 @@ NODE
(
cd "$update_fixture/project"
env PATH="$update_fixture/bin:$PATH" CALLS="$calls" FAIL_STEP="$fixture_step" \
THTCTL="$update_fixture/bin/tht" INSTALLATION="$update_fixture/installation.yaml" \
THT_BIN="$update_fixture/bin/tht" INSTALLATION="$update_fixture/installation.yaml" \
/bin/bash "$update_script"
) >"$output" 2>&1
status=$?
@@ -1339,7 +1339,6 @@ verify_reverse_proxy_nginx_guide() {
}
require_headings "$guide" "Nginx reverse-proxy guide" \
"Trust boundary" \
"Example configuration" \
"Validate and reload" \
"Test authentication and SSE"
require_text "$guide" "Nginx reverse-proxy guide" \
@@ -1437,16 +1436,69 @@ function nginxLocations(text) {
return locations;
}
const locations = nginxLocations(effectiveBlock);
const authLocations = locations.filter((location) => location.selector === "= /_authenticate");
if (authLocations.length !== 1) {
throw new Error("Nginx proxy must define exactly one authentication location");
}
const authLocation = authLocations[0].body;
const frontendLocations = locations.filter((location) =>
/proxy_pass\s+http:\/\/127\.0\.0\.1:8080\s*;/.test(location.body));
if (frontendLocations.length === 0) {
throw new Error("Nginx proxy lacks a frontend upstream location");
}
const authenticatedFrontendLocations = frontendLocations.filter((location) =>
/auth_request\s+\/_authenticate\s*;/.test(location.body));
const directFrontendLocations = frontendLocations.filter((location) =>
!/auth_request\s+\/_authenticate\s*;/.test(location.body));
if (directFrontendLocations.length > 1) {
throw new Error("Nginx direct OIDC mode contains an additional frontend bypass location");
}
for (const frontendLocation of frontendLocations) {
for (const token of [
"proxy_http_version 1.1;", "proxy_buffering off;", "proxy_cache off;",
"proxy_read_timeout 3600s;",
]) {
if (!frontendLocation.body.includes(token)) {
throw new Error(`Nginx proxy lacks structural token: ${token}`);
}
}
}
if (authenticatedFrontendLocations.length > 0) {
const authLocations = locations.filter((location) => location.selector === "= /_authenticate");
if (authLocations.length !== 1) {
throw new Error("Nginx proxy must define exactly one authentication location for upstream mode");
}
const authLocation = authLocations[0].body;
for (const [label, publicName, variable, upstream] of identities) {
const trustedName = publicName === "Is-Admin" ? "Is-Admin" : publicName;
const publicClear = new RegExp(`proxy_set_header\\s+X-Thoth-${escaped(publicName)}\\s+"";`);
const trustedHeader = `X-Thoth-Trusted-${trustedName}`;
const trustedClear = new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+"";`);
const authPublicAt = authLocation.search(publicClear);
const authTrustedAt = authLocation.search(trustedClear);
if (authPublicAt < 0) {
throw new Error(`Nginx auth location does not clear inbound ${label} identity`);
}
if (authTrustedAt < 0) {
throw new Error(`Nginx auth location does not clear inbound trusted ${label} identity`);
}
for (const frontendLocation of authenticatedFrontendLocations) {
const frontendPublicAt = frontendLocation.body.search(publicClear);
if (frontendPublicAt < 0) {
throw new Error(`Nginx frontend location does not clear inbound ${label} identity`);
}
const normalizedFrontend = frontendLocation.body.replace(/\s+/g, " ");
const captureAt = normalizedFrontend.search(new RegExp(`auth_request_set\\s+\\$${variable}\\s+\\$upstream_http_${upstream};`));
if (captureAt < 0) {
throw new Error(`Nginx frontend location does not capture authenticated ${label} identity`);
}
const mapAt = normalizedFrontend.search(new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`));
if (mapAt < 0) {
throw new Error(`Nginx frontend location does not map authenticated ${label} identity`);
}
}
if (new RegExp(`auth_request_set\\s+\\$${variable}|proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`).test(authLocation)) {
throw new Error(`Nginx auth location performs a forbidden ${label} capture or mapping`);
}
}
} else if (directFrontendLocations.length === 0) {
throw new Error("Nginx proxy lacks a direct or authenticated frontend path");
}
for (const location of locations) {
const upstreams = [...location.body.matchAll(/proxy_pass\s+([^;]+);/g)].map((match) => match[1].trim());
for (const upstream of upstreams) {
@@ -1455,41 +1507,9 @@ for (const location of locations) {
throw new Error(`Nginx location proxies to an unreviewed upstream: ${upstream}`);
}
}
for (const frontendLocation of frontendLocations) {
for (const frontendLocation of authenticatedFrontendLocations) {
if (!/auth_request\s+\/_authenticate\s*;/.test(frontendLocation.body)) {
throw new Error("Nginx frontend upstream location bypasses complete authentication contract");
}
}
for (const [label, publicName, variable, upstream] of identities) {
const trustedName = publicName === "Is-Admin" ? "Is-Admin" : publicName;
const publicClear = new RegExp(`proxy_set_header\\s+X-Thoth-${escaped(publicName)}\\s+"";`);
const trustedHeader = `X-Thoth-Trusted-${trustedName}`;
const trustedClear = new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+"";`);
const authPublicAt = authLocation.search(publicClear);
const authTrustedAt = authLocation.search(trustedClear);
if (authPublicAt < 0) {
throw new Error(`Nginx auth location does not clear inbound ${label} identity`);
}
if (authTrustedAt < 0) {
throw new Error(`Nginx auth location does not clear inbound trusted ${label} identity`);
}
for (const frontendLocation of frontendLocations) {
const frontendPublicAt = frontendLocation.body.search(publicClear);
if (frontendPublicAt < 0) {
throw new Error(`Nginx frontend location does not clear inbound ${label} identity`);
}
const normalizedFrontend = frontendLocation.body.replace(/\s+/g, " ");
const captureAt = normalizedFrontend.search(new RegExp(`auth_request_set\\s+\\$${variable}\\s+\\$upstream_http_${upstream};`));
if (captureAt < 0) {
throw new Error(`Nginx frontend location does not capture authenticated ${label} identity`);
}
const mapAt = normalizedFrontend.search(new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`));
if (mapAt < 0) {
throw new Error(`Nginx frontend location does not map authenticated ${label} identity`);
}
}
if (new RegExp(`auth_request_set\\s+\\$${variable}|proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`).test(authLocation)) {
throw new Error(`Nginx auth location performs a forbidden ${label} capture or mapping`);
throw new Error("Nginx authenticated frontend path bypasses complete authentication contract");
}
}
NODE
@@ -1504,21 +1524,20 @@ verify_reverse_proxy_caddy_guide() {
}
require_headings "$guide" "Caddy reverse-proxy guide" \
"Trust boundary" \
"Example configuration" \
"Validate and reload" \
"Test authentication and SSE"
require_text "$guide" "Caddy reverse-proxy guide" \
"Forwarding identity headers alone does not authenticate a user" \
"authentication gateway" \
"2xx" \
"automatic HTTPS" \
"Caddy terminates TLS" \
"frontend"
node - "$guide" <<'NODE'
const fs = require("fs");
const source = fs.readFileSync(process.argv[2], "utf8");
const block = [...source.matchAll(/```caddyfile\n([\s\S]*?)```/g)].map((match) => match[1]).join("\n");
const tokens = [
"thoth.example.com {", "route {",
"thoth.example.invalid {", "route {",
"forward_auth auth-gateway:4180 {", "uri /verify", "copy_headers {",
"reverse_proxy 127.0.0.1:8080 {", "flush_interval -1",
];
@@ -1730,15 +1749,15 @@ verify_manual() {
'export THT_SOURCE_ROOT=/absolute/path/to/ThothII'
'thothii-installation.yaml'
'workspaceRepository'
'"$THTCTL" --installation "$INSTALLATION" start'
'"$THTCTL" --installation "$INSTALLATION" doctor'
'"$THT_BIN" --installation "$INSTALLATION" start'
'"$THT_BIN" --installation "$INSTALLATION" doctor'
)
else
expected_steps=(
'THTCTL=/srv/thothii/operator/tht'
'THT_BIN=/srv/thothii/operator/tht'
'INSTALLATION=/srv/thothii/operator/thothii-installation.yaml'
'"$THTCTL" --installation "$INSTALLATION" start'
'"$THTCTL" --installation "$INSTALLATION" doctor'
'"$THT_BIN" --installation "$INSTALLATION" start'
'"$THT_BIN" --installation "$INSTALLATION" doctor'
'docs/install/examples/thothii-installation.server.yaml'
'compose.yaml'
'deploy/compose.server.yaml'
@@ -1829,7 +1848,7 @@ for required in (
ui = (root / "frontend/src/shell/WorkspaceManager.tsx").read_text()
for required in (
"Create a local workspace",
"Create a workspace repository",
"Update workspace repository",
"No workspace selection is required",
"Temporary files are deleted after the test",
@@ -2024,8 +2043,9 @@ if (Object.keys(config.services).sort().join(",") !== "core,embedding,embedding-
}
const core = config.services.core;
const frontend = config.services.frontend;
if (core.environment?.AUTH_MODE !== "upstream" || core.environment?.THOTH_PUBLIC_EXPOSURE !== "true") {
throw new Error("server installation example must fail closed behind upstream authentication");
if (core.environment?.THOTH_PUBLIC_EXPOSURE !== "true" ||
core.environment?.THT_AUTH_CONFIG_FILE !== "/run/thothii-auth/auth.yaml") {
throw new Error("server installation example must expose only the authenticated frontend");
}
if ((core.ports || []).length !== 0) throw new Error("server installation example published core");
const ports = frontend.ports || [];