docs: consolidate historical records and verify public manual publication
Publish documentation / publish (push) Successful in 29s
Publish documentation / publish (push) Successful in 29s
This commit is contained in:
+3
-205
@@ -1,207 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Current public guides and internal authentication contracts; no retired runbooks.
|
||||
set -euo pipefail
|
||||
|
||||
script_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
root=$script_root
|
||||
if [[ $# -gt 0 ]]; then
|
||||
[[ $# -eq 2 && $1 == "--root" && -d $2 ]] || {
|
||||
echo "usage: auth-docs-smoke.sh [--root DIRECTORY]" >&2
|
||||
exit 2
|
||||
}
|
||||
root=$(cd "$2" && pwd -P)
|
||||
fi
|
||||
docs=(
|
||||
"$root/docs/architecture/authentication.md"
|
||||
"$root/docs/install/authentication-local.md"
|
||||
"$root/docs/install/authentication-oidc.md"
|
||||
"$root/docs/install/authentik.md"
|
||||
"$root/docs/testing/authentication-manual-acceptance.md"
|
||||
"$root/docs/architecture/overview.md"
|
||||
"$root/docs/install/local.md"
|
||||
"$root/docs/install/server.md"
|
||||
"$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"
|
||||
"$root/PROJECT_STATE.md"
|
||||
"$root/mkdocs.yml"
|
||||
)
|
||||
|
||||
for path in "${docs[@]}"; do
|
||||
[[ -f "$path" ]] || { echo "auth docs smoke: missing $path" >&2; exit 1; }
|
||||
done
|
||||
|
||||
corpus=$(mktemp)
|
||||
trap 'rm -f "$corpus"' EXIT
|
||||
cat "${docs[@]}" >"$corpus"
|
||||
|
||||
required=(
|
||||
"tht auth"
|
||||
"groups"
|
||||
"TOT Admin"
|
||||
"THT_OIDC_CLIENT_SECRET"
|
||||
"THT_AUTHENTIK_API_TOKEN"
|
||||
"Remember me"
|
||||
"oidc_mapped_group_missing"
|
||||
"oidc_callback_failed"
|
||||
"session.read_all"
|
||||
"workspace.secrets.manage"
|
||||
"auth.diagnostics.read"
|
||||
)
|
||||
for term in "${required[@]}"; do
|
||||
rg -Fq "$term" "$corpus" || { echo "auth docs smoke: missing required term: $term" >&2; exit 1; }
|
||||
done
|
||||
|
||||
canonical_compose='docker compose --env-file deploy/env/local.env -f compose.yaml -f deploy/compose.local.yaml up --build -d'
|
||||
rg -Fxq "$canonical_compose" "$root/docs/install/local.md" || {
|
||||
echo "auth docs smoke: missing canonical local Compose command" >&2
|
||||
exit 1
|
||||
}
|
||||
if rg -n -F 'docker compose --env-file deploy/env/local.env +' "$corpus"; then
|
||||
echo "auth docs smoke: noncanonical local Compose command" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
nav_count=$(awk 'index($0, "architecture/authentication.md") { count++ } END { print count + 0 }' "$root/mkdocs.yml")
|
||||
[[ $nav_count == 1 ]] || {
|
||||
echo "auth docs smoke: authentication navigation must appear exactly once" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
python3 - "$root" <<'PY'
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
architecture = (root / "docs/architecture/authentication.md").read_text()
|
||||
|
||||
user_row = "| `user` | `session.use` |"
|
||||
admin_row = (
|
||||
"| `admin` | `session.use`, `session.read_all`, `session.manage_all`, `settings.manage`, "
|
||||
"`workspace.manage`, `workspace.secrets.manage`, `database.manage`, `pi.manage`, `auth.diagnostics.read` |"
|
||||
)
|
||||
if user_row not in architecture or admin_row not in architecture:
|
||||
raise SystemExit("auth docs smoke: role-to-permission map is not exact")
|
||||
|
||||
diagnostic_heading = "## Diagnostics and ordering"
|
||||
diagnostic_start = architecture.find(diagnostic_heading)
|
||||
diagnostic_end = architecture.find("\n## ", diagnostic_start + len(diagnostic_heading))
|
||||
diagnostic_section = architecture[diagnostic_start:diagnostic_end if diagnostic_end >= 0 else None]
|
||||
match = re.search(r"```text\n([\s\S]*?)```", diagnostic_section)
|
||||
expected_codes = [
|
||||
"auth_ready",
|
||||
"auth_config_incomplete",
|
||||
"auth_config_invalid",
|
||||
"auth_session_store_invalid",
|
||||
"local_user_registry_invalid",
|
||||
"local_admin_missing",
|
||||
"oidc_secret_missing",
|
||||
"oidc_discovery_unreachable",
|
||||
"oidc_issuer_mismatch",
|
||||
"oidc_jwks_unreachable",
|
||||
"oidc_group_catalog_unreachable",
|
||||
"oidc_group_catalog_unauthorized",
|
||||
"oidc_mapped_group_missing",
|
||||
"oidc_mapped_group_ambiguous",
|
||||
"oidc_groups_claim_invalid",
|
||||
"oidc_device_flow_unavailable",
|
||||
]
|
||||
actual_codes = [] if match is None else [line for line in match.group(1).splitlines() if line]
|
||||
if actual_codes != expected_codes:
|
||||
raise SystemExit("auth docs smoke: diagnostic code union is not exact")
|
||||
|
||||
for relative, language, forbidden, required in [
|
||||
("docs/install/reverse-proxy-caddy.md", "caddyfile", "forward_auth", "forward_auth"),
|
||||
("docs/install/reverse-proxy-nginx.md", "nginx", "auth_request", "auth_request"),
|
||||
]:
|
||||
source = (root / relative).read_text()
|
||||
direct_start = source.find("## Direct ThothII-managed OIDC")
|
||||
deprecated_start = source.find("## Deprecated upstream migration mode")
|
||||
if direct_start < 0 or deprecated_start <= direct_start:
|
||||
raise SystemExit(f"auth docs smoke: {relative} does not split direct and deprecated modes")
|
||||
direct = source[direct_start:deprecated_start]
|
||||
deprecated_end = source.find("\n## ", deprecated_start + 4)
|
||||
deprecated = source[deprecated_start:deprecated_end if deprecated_end >= 0 else None]
|
||||
blocks = re.findall(rf"```{language}\n([\s\S]*?)```", direct)
|
||||
direct_code = "\n".join(blocks)
|
||||
if "/api/auth/oidc/login" not in direct or "/api/auth/oidc/callback" not in direct:
|
||||
raise SystemExit(f"auth docs smoke: {relative} omits unchanged public OIDC paths")
|
||||
if re.search(rf"(?m)^\s*{forbidden}\b", direct_code):
|
||||
raise SystemExit(f"auth docs smoke: {relative} applies external auth in direct OIDC mode")
|
||||
deprecated_code = "\n".join(
|
||||
re.findall(rf"```{language}\n([\s\S]*?)```", deprecated)
|
||||
)
|
||||
if not re.search(rf"(?m)^\s*{required}\b", deprecated_code):
|
||||
raise SystemExit(f"auth docs smoke: {relative} omits scoped deprecated upstream auth")
|
||||
PY
|
||||
|
||||
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
|
||||
echo "auth docs smoke: plaintext password option" >&2
|
||||
exit 1
|
||||
fi
|
||||
if rg -n -i --pcre2 '(?:^|[,{[:space:]])password[[:space:]]*:[[:space:]]*\S+|"password"[[:space:]]*:[[:space:]]*(?:"[^"]+"|[^,}[:space:]]+)' "$corpus"; then
|
||||
echo "auth docs smoke: plaintext password field" >&2
|
||||
exit 1
|
||||
fi
|
||||
if rg -n -i --pcre2 '(?:unmapped|additional|extra)[^.\r\n]{0,160}groups?[^.\r\n]{0,160}(?:generate|produce|emit|cause|trigger|raise|create|result)[^.\r\n]{0,160}(?:warnings?|alerts?|advisory|advisories|notices?|notifications?|noise)|(?:warnings?|alerts?|advisory|advisories|notices?|notifications?|noise)[^.\r\n]{0,160}(?:generate|produce|emit|cause|trigger|raise|create|result)[^.\r\n]{0,160}(?:unmapped|additional|extra)[^.\r\n]{0,160}groups?' "$corpus"; then
|
||||
echo "auth docs smoke: misleading noise claim for unmapped groups" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Projected server authentication documentation contract.
|
||||
projection_docs=(
|
||||
"$root/docs/install/server.md"
|
||||
"$root/docs/install/authentication-local.md"
|
||||
"$root/docs/testing/authentication-manual-acceptance.md"
|
||||
"$root/docs/testing/psd-server-project-a-manual.md"
|
||||
"$root/docs/plans/2026-08-20-psd-server-project-a-standalone.md"
|
||||
"$root/PROJECT_STATE.md"
|
||||
)
|
||||
projection_corpus=$(mktemp)
|
||||
trap 'rm -f "$corpus" "$projection_corpus"' EXIT
|
||||
cat "${projection_docs[@]}" >"$projection_corpus"
|
||||
projection_required=(
|
||||
"canonical authentication root" "runtime projection" "CURRENT" "generations"
|
||||
"root:root 0700/0600" "10001:10001 0700/0600"
|
||||
"THT_AUTH_RUNTIME_ROOT"
|
||||
"auth status --json" "auth publish"
|
||||
"candidate or recovery" "Mac, Windows, and local direct-file authentication"
|
||||
"explicit authorization"
|
||||
)
|
||||
for term in "${projection_required[@]}"; do
|
||||
rg -Fqi "$term" "$projection_corpus" || {
|
||||
echo "auth docs smoke: missing runtime-projection term: $term" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
if rg -n -i --pcre2 "(?:useradd|groupadd)[^\n]{0,100}10001|10001[^\n]{0,100}(?:useradd|groupadd)" "$projection_corpus"; then
|
||||
echo "auth docs smoke: runtime projection creates a host 10001 identity" >&2
|
||||
exit 1
|
||||
fi
|
||||
if rg -n -i --pcre2 '(?:^|[.!?]\s+)(?:(?:mount|bind)\s+(?:the\s+)?(?:canonical authentication root|auth-canonical)\b[^\n]{0,120}(?:core|/run/thothii-auth)|(?:core|the core service)\s+(?:mounts?|binds?)\s+(?:the\s+)?(?:canonical authentication root|auth-canonical)\b)|auth-canonical[^\n]{0,120}:/run/thothii-auth' "$projection_corpus"; then
|
||||
echo "auth docs smoke: canonical authentication is mounted into core" >&2
|
||||
exit 1
|
||||
fi
|
||||
if rg -n -i --pcre2 '(?:(?:operators?\s+)?(?:may|can|should|must)\s+(?!not\b|never\b)(?:edit|write|modify|prune)|^\s*(?:sudo\s+)?(?:vi|vim|nano|sed|cp|mv|rm|tee|printf|echo)\b)[^\n]*(?:CURRENT|generations)' "$projection_corpus"; then
|
||||
echo "auth docs smoke: runtime projection permits direct selector or generation edits" >&2
|
||||
exit 1
|
||||
fi
|
||||
if rg -n -i --pcre2 '(?:^\s*(?:sudo\s+nginx\s+-T|(?:sudo\s+)?(?:printenv|env)(?:\s|$)|(?:sudo\s+)?(?:diff|cat|less|more|head|tail|yq)\s+[^\n]*(?:secret|auth\.yaml|users\.yaml))|(?:may|can|should|must)\s+(?!not\b|never\b)(?:dump|print|capture|attach|include|run)\b[^\n]*(?:password|auth\.yaml|users\.yaml|YAML|raw environment|nginx\s+-T|secret-bearing diff))' "$projection_corpus"; then
|
||||
echo "auth docs smoke: runtime projection documents unsafe evidence collection" >&2
|
||||
exit 1
|
||||
fi
|
||||
if rg -n -i --pcre2 'Project A (?:has been|was|is) started|legacy[- ]stack (?:has been|was|is) changed' "$projection_corpus"; then
|
||||
echo "auth docs smoke: runtime projection claims live Project A or legacy mutation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "auth docs smoke: required terms and forbidden wording checks passed"
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
exec python3 "$root/scripts/verify-auth-docs.py" auth "$@"
|
||||
|
||||
@@ -1,96 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
tmp=$(mktemp -d "${TMPDIR:-/tmp}/thoth-auth-docs.XXXXXX")
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
files=(
|
||||
docs/architecture/authentication.md
|
||||
docs/install/authentication-local.md
|
||||
docs/install/authentication-oidc.md
|
||||
docs/install/authentik.md
|
||||
docs/testing/authentication-manual-acceptance.md
|
||||
docs/architecture/overview.md
|
||||
docs/install/local.md
|
||||
docs/install/server.md
|
||||
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
|
||||
PROJECT_STATE.md
|
||||
mkdocs.yml
|
||||
)
|
||||
|
||||
make_fixture() {
|
||||
local name=${1:?fixture name required}
|
||||
local fixture="$tmp/$name"
|
||||
mkdir -p "$fixture"
|
||||
for relative in "${files[@]}"; do
|
||||
mkdir -p "$fixture/$(dirname "$relative")"
|
||||
cp "$root/$relative" "$fixture/$relative"
|
||||
done
|
||||
printf '%s\n' "$fixture"
|
||||
}
|
||||
|
||||
expect_rejected() {
|
||||
local name=${1:?fixture name required}
|
||||
local fixture_text=${2:?fixture text required}
|
||||
local expected=${3:?expected error required}
|
||||
local fixture output
|
||||
fixture=$(make_fixture "$name")
|
||||
printf '%s\n' "$fixture_text" >>"$fixture/README.md"
|
||||
output="$tmp/$name.output"
|
||||
if "$root/scripts/auth-docs-smoke.sh" --root "$fixture" >"$output" 2>&1; then
|
||||
echo "auth docs fixture unexpectedly passed: $name" >&2
|
||||
exit 1
|
||||
fi
|
||||
rg -Fq "$expected" "$output" || {
|
||||
echo "auth docs fixture failed for the wrong reason: $name" >&2
|
||||
sed -n '1,20p' "$output" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "auth docs negative fixture rejected: $name"
|
||||
}
|
||||
|
||||
positive=$(make_fixture positive)
|
||||
printf '%s\n' \
|
||||
'Use --password-file <file>; never pass a password value.' \
|
||||
'Additional unmapped groups are silently ignored without warnings, alerts, or advisories.' \
|
||||
>>"$positive/README.md"
|
||||
"$root/scripts/auth-docs-smoke.sh" --root "$positive" >/dev/null
|
||||
echo "auth docs positive fixture passed"
|
||||
|
||||
expect_rejected thothctl-intervening \
|
||||
'Run thothctl --installation <descriptor> --json auth check.' \
|
||||
'forbidden obsolete host CLI wording'
|
||||
expect_rejected alternate-admin \
|
||||
'Run thothii-admin users list.' \
|
||||
'forbidden obsolete host CLI wording'
|
||||
expect_rejected password-option \
|
||||
'Run tht auth user add demo --password example-value.' \
|
||||
'plaintext password option'
|
||||
expect_rejected yaml-password \
|
||||
'password: example-value' \
|
||||
'plaintext password field'
|
||||
expect_rejected json-password \
|
||||
'{"password": "example-value"}' \
|
||||
'plaintext password field'
|
||||
expect_rejected unmapped-warning \
|
||||
'Unmapped OIDC groups generate warnings.' \
|
||||
'misleading noise claim for unmapped groups'
|
||||
expect_rejected unmapped-alert \
|
||||
'Unmapped provider groups trigger operator alerts.' \
|
||||
'misleading noise claim for unmapped groups'
|
||||
expect_rejected unmapped-advisory \
|
||||
'An advisory is emitted for every unmapped group.' \
|
||||
'misleading noise claim for unmapped groups'
|
||||
expect_rejected compose-plus \
|
||||
'docker compose --env-file deploy/env/local.env + -f compose.yaml -f deploy/compose.local.yaml up --build -d' \
|
||||
'noncanonical local Compose command'
|
||||
|
||||
echo "auth docs smoke fixture suite passed"
|
||||
exec python3 "$root/scripts/test-verify-auth-docs.py" auth
|
||||
|
||||
@@ -110,18 +110,13 @@ if (JSON.stringify(config).includes("fixture-model-api-key")) {
|
||||
NODE
|
||||
done
|
||||
|
||||
for manual in "$root/README.md" "$root/docs/installazione-docker-4-contesti.md"; do
|
||||
grep -Fq -- './scripts/run-stack.sh' "$manual" || {
|
||||
echo "${manual#"$root/"} lacks the canonical local launcher" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -Fq -- 'docker compose --env-file deploy/env/server.env' "$manual" \
|
||||
&& grep -Fq -- '-f compose.yaml -f deploy/compose.server.yaml' "$manual" || {
|
||||
echo "${manual#"$root/"} lacks the canonical server base+profile command" >&2
|
||||
for manual in "$root/docs/install/standalone-manual-it.md" "$root/docs/install/standalone-manual-en.md"; do
|
||||
grep -Fq -- '--configure-only' "$manual" && grep -Fq -- 'catalog-migrate' "$manual" || {
|
||||
echo "${manual#"$root/"} omits explicit configuration or migration" >&2
|
||||
exit 1
|
||||
}
|
||||
if rg -q 'local-compose\.workspace-registry|server-compose\.workspace-registry' "$manual"; then
|
||||
echo "${manual#"$root/"} still references a superseded standalone Compose example" >&2
|
||||
echo "${manual#"$root/"} references a retired Compose example" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -13,7 +13,7 @@ targets=(
|
||||
deploy/secrets/README.md
|
||||
docs/install
|
||||
docs/index.md
|
||||
docs/installazione-docker-4-contesti.md
|
||||
docs/operations/compose-reference.md
|
||||
scripts/build-local.sh
|
||||
scripts/build-local.ps1
|
||||
scripts/docker-smoke.sh
|
||||
@@ -43,7 +43,7 @@ case "$rg_status" in
|
||||
;;
|
||||
esac
|
||||
|
||||
for document in README.md docs/installazione-docker-4-contesti.md; do
|
||||
for document in docs/operations/compose-reference.md; do
|
||||
grep -Fq -- '-f deploy/compose.session-server.yaml.example' "$document" || {
|
||||
echo "$document omits the required public-server session override" >&2
|
||||
exit 1
|
||||
|
||||
@@ -40,7 +40,7 @@ if [[ -d docker ]]; then
|
||||
done < <(find docker -type f -print0)
|
||||
fi
|
||||
|
||||
for file in README.md .env.example docs/installazione-docker-4-contesti.md; do
|
||||
for file in README.md .env.example docs/operations/compose-reference.md; do
|
||||
add_file install_files "$file"
|
||||
done
|
||||
if [[ -d docs/install ]]; then
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mutation fixtures for current auth/DWH documentation; never contacts services."""
|
||||
import importlib.util
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts/verify-auth-docs.py"
|
||||
spec = importlib.util.spec_from_file_location("authdocs", SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "auth"
|
||||
assert mode in ("auth", "dwh")
|
||||
relative = next(iter(module.AUTH if mode == "auth" else module.DWH))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="thoth-auth-docs-") as tmp:
|
||||
fixture = Path(tmp)
|
||||
shutil.copytree(ROOT / "docs", fixture / "docs")
|
||||
for item in ROOT.glob("*.md"):
|
||||
shutil.copy2(item, fixture / item.name)
|
||||
target = fixture / relative
|
||||
original = target.read_text()
|
||||
|
||||
def check(expected=None):
|
||||
result = subprocess.run([sys.executable, str(SCRIPT), mode, "--root", tmp], capture_output=True, text=True)
|
||||
if expected is None:
|
||||
assert result.returncode == 0, result.stderr
|
||||
else:
|
||||
assert result.returncode != 0 and expected in result.stderr, result.stderr
|
||||
|
||||
check()
|
||||
mutations = [
|
||||
("curl -k https://example.invalid", "TLS bypass"),
|
||||
("curl --insecure https://example.invalid", "TLS bypass"),
|
||||
("verify_tls=false", "TLS bypass"),
|
||||
("DWH_API_KEY=synthetic", "raw environment secret"),
|
||||
('curl -H "X-API-Key: synthetic" https://example.invalid', "raw key header"),
|
||||
("tht auth user add demo --password synthetic", "secret argument"),
|
||||
("password: synthetic", "plaintext password"),
|
||||
("chmod 644 /protected/demo.key", "world-readable secret"),
|
||||
("sudo nginx -T", "raw nginx capture"),
|
||||
("git diff /protected/secret", "raw diff capture"),
|
||||
("docker compose up dwh-auth", "Compose coupling"),
|
||||
("thtdwh_v1." + "a" * 16 + "." + "b" * 43, "credential literal"),
|
||||
("secret_sha256: " + "a" * 64, "credential digest"),
|
||||
("auth-canonical:/run/thothii-auth:ro", "canonical auth root"),
|
||||
("useradd --uid 10001 core", "host runtime identity"),
|
||||
("nano /protected/generations/one/auth.yaml", "direct auth projection edit"),
|
||||
]
|
||||
for snippet, error in mutations:
|
||||
target.write_text(original + "\n```sh\n" + snippet + "\n```\n")
|
||||
check(error)
|
||||
target.write_text(original + '\nDo not use `curl -k`.\n```sh\nDWH_API_KEY_FILE=/protected/demo.key\n```\n')
|
||||
check()
|
||||
if mode == "auth":
|
||||
target.write_text(original.replace("`memory.manage`, ", ""))
|
||||
check("role-to-permission")
|
||||
target.write_text(original.replace("auth_ready\n", "auth_unknown\n"))
|
||||
check("diagnostic code")
|
||||
target.write_text(original + "\n[broken](missing-file.md)\n")
|
||||
check("broken local link")
|
||||
target.unlink()
|
||||
check("missing")
|
||||
print(f"{mode} documentation mutation fixtures passed")
|
||||
@@ -1,319 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
verify="$root/scripts/verify-dwh-auth-docs.sh"
|
||||
temp_root=
|
||||
|
||||
report_pass() {
|
||||
printf 'case=%s status=PASS\n' "$1"
|
||||
}
|
||||
|
||||
report_fail() {
|
||||
printf 'case=%s status=FAIL\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ "$temp_root" == /tmp/thothii-dwh-auth-docs.* && -d "$temp_root" ]]; then
|
||||
rm -rf -- "$temp_root"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ -x "$verify" ]] || report_fail verifier_missing
|
||||
|
||||
temp_root=$(mktemp -d /tmp/thothii-dwh-auth-docs.XXXXXXXX) || report_fail fixture_root
|
||||
fixture_root="$temp_root/fixture"
|
||||
mkdir -p "$fixture_root/docs/install" "$fixture_root/docs/operations" \
|
||||
"$fixture_root/docs/testing/evidence" "$fixture_root/scripts" "$fixture_root/deploy/psd"
|
||||
|
||||
for relative in \
|
||||
docs/install/dwh-auth-server.md \
|
||||
docs/install/dwh-auth-client-enrollment.md \
|
||||
docs/install/dwh-auth-tls.md \
|
||||
docs/operations/psd-dwh-auth-rollout.md \
|
||||
docs/testing/dwh-auth-manual-acceptance.md \
|
||||
docs/testing/evidence/psd-dwh-auth-rollout-report-template.md \
|
||||
docs/install/local-workspace-registry.md \
|
||||
docs/install/server-workspace-registry.md \
|
||||
docs/install/psd-workspace-setup.md \
|
||||
docs/guida-utente.md \
|
||||
docs/index.md \
|
||||
mkdocs.yml; do
|
||||
mkdir -p "$fixture_root/$(dirname "$relative")"
|
||||
cp "$root/$relative" "$fixture_root/$relative"
|
||||
done
|
||||
cp -a "$root/docs/." "$fixture_root/docs/"
|
||||
cp "$root/deploy/psd/workspace-bindings.env.example" "$fixture_root/deploy/psd/workspace-bindings.env.example"
|
||||
|
||||
"$verify" --root "$fixture_root" || report_fail positive_source
|
||||
report_pass positive_source
|
||||
|
||||
expect_rejected() {
|
||||
local name=$1 target=$2 addition=$3
|
||||
local case_root="$temp_root/$name"
|
||||
cp -a "$fixture_root" "$case_root"
|
||||
printf '\n%s\n' "$addition" >>"$case_root/$target"
|
||||
if "$verify" --root "$case_root" >/dev/null 2>&1; then
|
||||
report_fail "$name"
|
||||
fi
|
||||
report_pass "$name"
|
||||
}
|
||||
|
||||
exercise_header_file_curl() {
|
||||
local v1_header="$temp_root/synthetic-v1.header"
|
||||
local legacy_header="$temp_root/synthetic-legacy.header"
|
||||
command -v curl >/dev/null 2>&1 || report_fail header_file_curl_command
|
||||
printf '%s\n' 'X-API-Key: synthetic-v1' >"$v1_header"
|
||||
printf '%s\n' 'X-API-Key: legacy opaque value /:[]' >"$legacy_header"
|
||||
chmod 0600 "$v1_header" "$legacy_header"
|
||||
if ! python3 - "$v1_header" "$legacy_header" <<'PY'
|
||||
import http.server
|
||||
import pathlib
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
accepted = {"synthetic-v1", "legacy opaque value /:[]"}
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, _format, *_args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.send_response(204 if self.headers.get("X-API-Key") in accepted else 401)
|
||||
self.end_headers()
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
for header_file in sys.argv[1:]:
|
||||
if stat.S_IMODE(pathlib.Path(header_file).stat().st_mode) != 0o600:
|
||||
raise SystemExit(1)
|
||||
result = subprocess.run(
|
||||
["curl", "--silent", "--show-error", "--output", "/dev/null", "--write-out", "%{http_code}", "--header", f"@{header_file}", f"http://127.0.0.1:{server.server_port}/rpc/ping"],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0 or result.stdout != "204":
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
PY
|
||||
then
|
||||
report_fail header_file_curl_synthetic
|
||||
fi
|
||||
report_pass header_file_curl_synthetic
|
||||
}
|
||||
|
||||
# Build synthetic only-in-fixture text at runtime: it is never a provisioned credential.
|
||||
fake_key="thtdwh_v1.$(printf 'A%.0s' {1..16}).$(printf 'A%.0s' {1..43})"
|
||||
fake_digest="$(printf 'A%.0s' {1..43})"
|
||||
|
||||
expect_rejected credential_literal docs/install/dwh-auth-client-enrollment.md "$fake_key"
|
||||
expect_rejected opaque_legacy_header_literal docs/install/dwh-auth-client-enrollment.md "curl --header 'X-API-Key: opaque-legacy-fixture' https://example.invalid/dwh/rpc/ping"
|
||||
expect_rejected credential_digest_literal docs/testing/evidence/psd-dwh-auth-rollout-report-template.md "\"secret_sha256\": \"$fake_digest\""
|
||||
expect_rejected curl_insecure docs/install/dwh-auth-tls.md 'curl -k https://example.invalid/dwh/rpc/ping'
|
||||
expect_rejected tls_disabled docs/install/dwh-auth-tls.md 'verify_tls=false'
|
||||
expect_rejected secret_in_environment docs/install/dwh-auth-client-enrollment.md "export THT_WS_PSD_CLINICAL_DWH_API_KEY=$fake_key"
|
||||
expect_rejected secret_in_argv docs/install/dwh-auth-client-enrollment.md "curl -H 'X-API-Key: $fake_key' https://example.invalid/dwh/rpc/ping"
|
||||
expect_rejected curl_config_artifact docs/install/dwh-auth-server.md 'curl --config /root/dwh-auth-provision/request.conf https://example.invalid/dwh/rpc/ping'
|
||||
expect_rejected world_readable_secret docs/install/dwh-auth-server.md 'chmod 0644 /root/dwh-auth-provision/client.key'
|
||||
expect_rejected sudo_raw_nginx_capture docs/operations/psd-dwh-auth-rollout.md 'sudo nginx -T > /tmp/nginx-full.conf'
|
||||
expect_rejected raw_diff_capture docs/operations/psd-dwh-auth-rollout.md 'sudo diff -u /etc/nginx/sites-available/policlinicosandonato /root/backup.conf'
|
||||
expect_rejected git_raw_diff_capture docs/operations/psd-dwh-auth-rollout.md 'git diff --no-index /root/old.conf /root/new.conf'
|
||||
expect_rejected raw_nginx_capture docs/operations/psd-dwh-auth-rollout.md 'nginx -T > /tmp/nginx-full.conf'
|
||||
expect_rejected compose_coupling docs/install/dwh-auth-server.md 'docker compose up dwh-auth'
|
||||
|
||||
expect_replacement_rejected() {
|
||||
local name=$1 target=$2 needle=$3 replacement=$4
|
||||
local case_root="$temp_root/$name"
|
||||
cp -a "$fixture_root" "$case_root"
|
||||
[[ $(grep -Foc -- "$needle" "$case_root/$target") -eq 1 ]] || report_fail "${name}_fixture"
|
||||
sed -i "s~$needle~$replacement~" "$case_root/$target"
|
||||
if "$verify" --root "$case_root" >/dev/null 2>&1; then report_fail "$name"; fi
|
||||
report_pass "$name"
|
||||
}
|
||||
|
||||
|
||||
expect_global_replacement_rejected() {
|
||||
local name=$1 target=$2 needle=$3 replacement=$4
|
||||
local case_root="$temp_root/$name"
|
||||
cp -a "$fixture_root" "$case_root"
|
||||
[[ $(grep -Foc -- "$needle" "$case_root/$target") -gt 0 ]] || report_fail "${name}_fixture"
|
||||
sed -i "s~$needle~$replacement~g" "$case_root/$target"
|
||||
if "$verify" --root "$case_root" >/dev/null 2>&1; then report_fail "$name"; fi
|
||||
report_pass "$name"
|
||||
}
|
||||
|
||||
exercise_header_file_curl
|
||||
|
||||
exercise_journal_scanner() {
|
||||
local journal_source="$fixture_root/docs/install/dwh-auth-server.md"
|
||||
local journal_code
|
||||
journal_code=$(python3 - "$journal_source" <<'PY2'
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
source = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||
start = source.index("if sudo python3 -c '") + len("if sudo python3 -c '")
|
||||
end = source.index("' \"$since\" \"$v1_key_file\" \"$legacy_key_file\"; then", start)
|
||||
print(source[start:end])
|
||||
PY2
|
||||
) || report_fail journal_extract
|
||||
[[ "$journal_code" == *"process.stdout.read1("* ]] || report_fail journal_chunk_reader
|
||||
[[ "$journal_code" != *"enumerate(process.stdout"* ]] || report_fail journal_unbounded_reader
|
||||
|
||||
local fake_bin="$temp_root/fake-bin"
|
||||
local fake_journalctl="$fake_bin/journalctl"
|
||||
local data_file="$temp_root/journal.data"
|
||||
local pid_file="$temp_root/journal.pid"
|
||||
local stdout_file="$temp_root/journal.stdout"
|
||||
local stderr_file="$temp_root/journal.stderr"
|
||||
mkdir -p "$fake_bin"
|
||||
python3 - "$fake_journalctl" <<'PY2'
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
path.write_text("""#!/usr/bin/env python3
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
pathlib.Path(os.environ["JOURNAL_PID_FILE"]).write_text(str(os.getpid()))
|
||||
data = pathlib.Path(os.environ["JOURNAL_DATA_FILE"]).read_bytes()
|
||||
for offset in range(0, len(data), 997):
|
||||
os.write(sys.stdout.fileno(), data[offset:offset + 997])
|
||||
if os.environ.get("JOURNAL_HOLD") == "1":
|
||||
time.sleep(30)
|
||||
raise SystemExit(int(os.environ.get("JOURNAL_EXIT", "0")))
|
||||
""")
|
||||
path.chmod(0o755)
|
||||
PY2
|
||||
|
||||
local key_file="$temp_root/synthetic-real.key"
|
||||
local legacy_file="$temp_root/synthetic-legacy.key"
|
||||
printf '%s' 'REAL_SECRET_SYNTHETIC' >"$key_file"
|
||||
printf '%s' 'LEGACY_SECRET_SYNTHETIC' >"$legacy_file"
|
||||
chmod 0600 "$key_file" "$legacy_file"
|
||||
|
||||
run_journal_case() {
|
||||
local name=$1 expected=$2 data=$3 exit_code=$4 hold=$5
|
||||
printf '%s' "$data" >"$data_file"
|
||||
: >"$pid_file" "$stdout_file" "$stderr_file"
|
||||
if PATH="$fake_bin:$PATH" JOURNAL_DATA_FILE="$data_file" JOURNAL_PID_FILE="$pid_file" JOURNAL_EXIT="$exit_code" JOURNAL_HOLD="$hold" \
|
||||
python3 -c "$journal_code" synthetic-since "$key_file" "$legacy_file" >"$stdout_file" 2>"$stderr_file"; then
|
||||
actual=0
|
||||
else
|
||||
actual=$?
|
||||
fi
|
||||
[[ "$actual" -eq "$expected" ]] || report_fail "journal_${name}_status"
|
||||
[[ ! -s "$stdout_file" && ! -s "$stderr_file" ]] || report_fail "journal_${name}_output"
|
||||
if [[ -s "$pid_file" ]]; then
|
||||
pid=$(cat "$pid_file")
|
||||
[[ ! -e "/proc/$pid" ]] || report_fail "journal_${name}_reaped"
|
||||
fi
|
||||
report_pass "journal_${name}"
|
||||
}
|
||||
|
||||
local python_bin
|
||||
python_bin=$(command -v python3) || report_fail python_missing
|
||||
|
||||
run_journal_case clean 0 $'INFO clean\n' 0 0
|
||||
run_journal_case oversized_line 2 "$(printf 'A%.0s' {1..1048577})" 0 0
|
||||
run_journal_case too_many_lines 2 "$(printf 'x\n%.0s' {1..10001})" 0 0
|
||||
run_journal_case child_failure 2 $'INFO child failure\n' 7 0
|
||||
run_journal_case actual_key_across_chunk 1 "$(printf 'A%.0s' {1..65530})REAL_SECRET_SYNTHETIC" 0 1
|
||||
|
||||
local empty_path="$temp_root/empty-path"
|
||||
mkdir -p "$empty_path"
|
||||
printf '%s' 'INFO spawn failure\n' >"$data_file"
|
||||
: >"$pid_file" "$stdout_file" "$stderr_file"
|
||||
if PATH="$empty_path" JOURNAL_DATA_FILE="$data_file" JOURNAL_PID_FILE="$pid_file" JOURNAL_EXIT=0 JOURNAL_HOLD=0 \
|
||||
"$python_bin" -c "$journal_code" synthetic-since "$key_file" "$legacy_file" >"$stdout_file" 2>"$stderr_file"; then
|
||||
actual=0
|
||||
else
|
||||
actual=$?
|
||||
fi
|
||||
[[ "$actual" -eq 2 ]] || report_fail journal_spawn_failure_status
|
||||
[[ ! -s "$stdout_file" && ! -s "$stderr_file" ]] || report_fail journal_spawn_failure_output
|
||||
[[ ! -s "$pid_file" ]] || report_fail journal_spawn_failure_child
|
||||
report_pass journal_spawn_failure
|
||||
|
||||
chmod 000 "$key_file"
|
||||
: >"$pid_file" "$stdout_file" "$stderr_file"
|
||||
if PATH="$fake_bin:$PATH" JOURNAL_DATA_FILE="$data_file" JOURNAL_PID_FILE="$pid_file" JOURNAL_EXIT=0 JOURNAL_HOLD=0 \
|
||||
"$python_bin" -c "$journal_code" synthetic-since "$key_file" "$legacy_file" >"$stdout_file" 2>"$stderr_file"; then
|
||||
actual=0
|
||||
else
|
||||
actual=$?
|
||||
fi
|
||||
chmod 0600 "$key_file"
|
||||
[[ "$actual" -eq 2 ]] || report_fail journal_key_unreadable_status
|
||||
[[ ! -s "$stdout_file" && ! -s "$stderr_file" ]] || report_fail journal_key_unreadable_output
|
||||
[[ ! -s "$pid_file" ]] || report_fail journal_key_unreadable_child
|
||||
report_pass journal_key_unreadable
|
||||
|
||||
python3 - "$key_file" <<'PY2'
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
pathlib.Path(sys.argv[1]).write_bytes(b"K" * 65_537)
|
||||
PY2
|
||||
run_journal_case long_key_across_chunks 1 "$(printf 'A%.0s' {1..65535})$(printf 'K%.0s' {1..65537})" 0 1
|
||||
}
|
||||
|
||||
exercise_journal_scanner
|
||||
|
||||
expect_replacement_rejected missing_exact_gui_label docs/install/dwh-auth-client-enrollment.md "Validate workspace source" "Validate workspace"
|
||||
expect_replacement_rejected server_transport_contradiction docs/operations/psd-dwh-auth-rollout.md 'server PSD: `postgres_direct` read-only' 'server PSD: `rest_api` read-only'
|
||||
expect_replacement_rejected missing_mac_local_marker deploy/psd/workspace-bindings.env.example "Mac/local/remota" "server PSD"
|
||||
expect_replacement_rejected missing_private_ca docs/install/psd-workspace-setup.md "TLS_CA_FILE" "TLS_CA_REMOVED"
|
||||
expect_global_replacement_rejected missing_socket_path docs/install/dwh-auth-server.md "/run/dwh-auth/verify.sock" "/run/dwh-auth/other.sock"
|
||||
expect_replacement_rejected rest_transport_flag deploy/psd/workspace-bindings.env.example "THT_WS_PSD_CLINICAL_DWH_TRANSPORT=rest_api" "THT_WS_PSD_CLINICAL_DWH_TRANSPORT=postgres_direct"
|
||||
|
||||
expect_replacement_rejected wrong_key_output_path docs/install/dwh-auth-server.md "key_output=/root/dwh-auth-provision/psd-mac-primary.key" "key_output=/root/dwh-auth-provision/psd-mac-primary-primary.key"
|
||||
expect_replacement_rejected wrong_legacy_key_id docs/install/dwh-auth-server.md "legacy_key_id=legacy-shared" "legacy_key_id=legacy-public-key-id"
|
||||
expect_global_replacement_rejected missing_header_file_transport docs/install/dwh-auth-server.md '--header "@' '--header "X-API-Key: '
|
||||
expect_global_replacement_rejected missing_legacy_header_file docs/install/dwh-auth-server.md "legacy_header_file" "retired_auth_header"
|
||||
expect_global_replacement_rejected missing_safe_restore docs/install/dwh-auth-server.md "registry_staging" "registry_overlay"
|
||||
expect_replacement_rejected unsafe_restore_overlay docs/install/dwh-auth-server.md "sudo tar --acls --xattrs -C \"\$registry_staging\" -xf \"\$registry_backup\"" "sudo tar --acls --xattrs -C /var/lib -xf \"\$registry_backup\""
|
||||
expect_replacement_rejected missing_guarded_registry_publish docs/install/dwh-auth-server.md "if ! sudo mv -T -- \"\$registry_root\" \"\$registry_previous\"; then" "sudo mv -T -- \"\$registry_root\" \"\$registry_previous\""
|
||||
expect_replacement_rejected missing_candidate_check_guard docs/install/dwh-auth-server.md "if ! sudo /usr/local/sbin/dwh-auth --registry-root \"\$registry_candidate\" check; then" "sudo /usr/local/sbin/dwh-auth --registry-root \"\$registry_candidate\" check"
|
||||
expect_replacement_rejected unsafe_registry_publish_target docs/install/dwh-auth-server.md "if ! sudo mv -T -- \"\$registry_root\" \"\$registry_previous\"; then" "if ! sudo mv \"\$registry_root\" \"\$registry_previous\"; then"
|
||||
expect_replacement_rejected missing_key_file_byte_guard docs/install/dwh-auth-server.md "key_file_bytes=PASS" "key_file_bytes=SKIPPED"
|
||||
expect_replacement_rejected missing_failed_registry_publish_target docs/install/dwh-auth-server.md "if ! sudo mv -T -- \"\$registry_root\" \"\$registry_staging/failed-dwh-auth\"; then" "if ! sudo mv \"\$registry_root\" \"\$registry_staging/failed-dwh-auth\"; then"
|
||||
expect_global_replacement_rejected missing_rollback_registry_publish_target docs/install/dwh-auth-server.md "if ! sudo mv -T -- \"\$registry_previous\" \"\$registry_root\"; then" "if ! sudo mv \"\$registry_previous\" \"\$registry_root\"; then"
|
||||
expect_global_replacement_rejected missing_exact_header_bytes docs/install/dwh-auth-server.md 'dd if="$1" bs=65536 status=none' 'dd if="$1" bs=1'
|
||||
|
||||
|
||||
expect_replacement_rejected missing_actual_key_journal_scan docs/install/dwh-auth-server.md "journal_actual_key_scan=PASS" "journal_generic_scan=PASS"
|
||||
expect_replacement_rejected missing_journal_actual_key_match docs/install/dwh-auth-server.md "any(needle in searchable for needle in needles)" "False"
|
||||
expect_replacement_rejected missing_journal_returncode_guard docs/install/dwh-auth-server.md "if process.returncode != 0:" "if process.returncode == 0:"
|
||||
journal_contract_source="$fixture_root/docs/install/dwh-auth-server.md"
|
||||
if grep -Fq 'result.stdout.splitlines' "$journal_contract_source" \
|
||||
|| grep -Fq 'enumerate(process.stdout' "$journal_contract_source" \
|
||||
|| ! grep -Fq 'max_journal_bytes = 1_048_576' "$journal_contract_source" \
|
||||
|| ! grep -Fq 'max_journal_lines = 10_000' "$journal_contract_source" \
|
||||
|| ! grep -Fq 'max_chunk_bytes = 65_536' "$journal_contract_source" \
|
||||
|| ! grep -Fq 'process.stdout.read1(' "$journal_contract_source" \
|
||||
|| ! grep -Fq 'remaining = max_journal_bytes - bytes_seen' "$journal_contract_source" \
|
||||
|| ! grep -Fq 'searchable = carry + chunk' "$journal_contract_source" \
|
||||
|| ! grep -Fq 'process.kill()' "$journal_contract_source"; then
|
||||
report_fail journal_streaming_limits
|
||||
fi
|
||||
report_pass journal_streaming_limits
|
||||
expect_replacement_rejected missing_socket_legacy_probe docs/install/dwh-auth-server.md "socket_legacy=PASS" "socket_legacy=SKIPPED"
|
||||
expect_replacement_rejected missing_https_post_revoke_v1 docs/install/dwh-auth-server.md "https_v1_post_revoke=PASS" "https_v1_post_revoke=SKIPPED"
|
||||
expect_replacement_rejected missing_https_post_revoke_legacy docs/install/dwh-auth-server.md "https_legacy_post_revoke=PASS" "https_legacy_post_revoke=SKIPPED"
|
||||
expect_global_replacement_rejected rollout_post_revoke_status docs/operations/psd-dwh-auth-rollout.md "v1=2xx post-revoca" "v1=204 post-revoca"
|
||||
|
||||
report_pass summary
|
||||
exec python3 "$root/scripts/test-verify-auth-docs.py" dwh
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate current authentication documentation, not retired rollout transcripts."""
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
AUTH = {
|
||||
"docs/architecture/authentication.md": ["## Diagnostics and ordering", "HttpOnly", "SameSite=Lax", "authRevision", "401", "403", "direct, non-empty", "ignored silently"],
|
||||
"docs/install/authentication-local.md": ["tht auth", "--password-file"],
|
||||
"docs/install/authentication-oidc.md": ["groups", "THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN", "/api/auth/oidc/callback"],
|
||||
"docs/install/authentik.md": ["groups"],
|
||||
"docs/install/authentication-upstream.md": ["AUTH_MODE=upstream"],
|
||||
"docs/testing/authentication-manual-acceptance.md": ["Remember me", "403", "Header identità inventati", "non sostituiscono"],
|
||||
}
|
||||
DWH = {
|
||||
"docs/install/dwh-auth-server.md": ["/var/lib/dwh-auth/", "/run/dwh-auth/verify.sock", "key create", "key revoke", "encrypted", "401", "503"],
|
||||
"docs/install/dwh-auth-client-enrollment.md": ["rest_api", "postgres_direct", "ssh_tunnel", "API_KEY_FILE", "TLS_CA_FILE", "/rpc/ping", "401", "503"],
|
||||
"docs/install/dwh-auth-tls.md": ["SAN", "TLS_CA_FILE", "openssl x509 -noout -fingerprint -sha256"],
|
||||
}
|
||||
CODES = """auth_ready auth_config_incomplete auth_config_invalid auth_session_store_invalid
|
||||
local_user_registry_invalid local_admin_missing oidc_secret_missing oidc_discovery_unreachable
|
||||
oidc_issuer_mismatch oidc_jwks_unreachable oidc_group_catalog_unreachable
|
||||
oidc_group_catalog_unauthorized oidc_mapped_group_missing oidc_mapped_group_ambiguous
|
||||
oidc_groups_claim_invalid oidc_device_flow_unavailable""".split()
|
||||
ADMIN = """session.use session.read_all session.manage_all settings.manage workspace.manage
|
||||
workspace.secrets.manage database.manage memory.manage evidence.manage pi.manage auth.diagnostics.read""".split()
|
||||
|
||||
|
||||
def verify(root, mode):
|
||||
requirements = AUTH if mode == "auth" else DWH
|
||||
sources = {}
|
||||
for relative, tokens in requirements.items():
|
||||
path = root / relative
|
||||
if not path.is_file():
|
||||
raise ValueError(f"missing {relative}")
|
||||
source = path.read_text()
|
||||
sources[relative] = source
|
||||
for token in tokens:
|
||||
if token.lower() not in source.lower():
|
||||
raise ValueError(f"{relative}: missing topic {token}")
|
||||
for target in re.findall(r"(?<!!)\[[^]]*\]\(([^)#]+)(?:#[^)]+)?\)", source):
|
||||
if "://" in target or target.startswith("mailto:"):
|
||||
continue
|
||||
candidate = (path.parent / target).resolve()
|
||||
if not candidate.is_file() or root.resolve() not in candidate.parents:
|
||||
raise ValueError(f"{relative}: broken local link {target}")
|
||||
|
||||
corpus = "\n".join(sources.values())
|
||||
# Negative prose can explain a forbidden command; only executable examples are
|
||||
# checked for command safety. Credential literals are forbidden everywhere.
|
||||
code = "\n".join(re.findall(r"```[^\n]*\n([\s\S]*?)```", corpus))
|
||||
for pattern, label, source in [
|
||||
(r"\b(?:thothctl|thothii-admin)\b", "obsolete host CLI", corpus),
|
||||
(r"(?im)^\s*(?:[\"']?password[\"']?)\s*:\s*\S+|\"password\"\s*:\s*\"[^\"]+\"", "plaintext password", code),
|
||||
(r"thtdwh_v1\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{43}", "credential literal", corpus),
|
||||
(r"(?mi)^\s*[\"']?secret_sha256[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9_-]{16,}", "credential digest", corpus),
|
||||
(r"(?mi)^\s*(?:export\s+)?[A-Z][A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD)\s*=\s*[^\s#]+", "raw environment secret", code),
|
||||
(r"(?i)--(?:password|api-key|token)(?!-file)\b(?:\s+|=)\S+", "secret argument", code),
|
||||
(r"(?i)(?:-H|--header)\s+['\"][^'\"]*X-API-Key\s*:", "raw key header", code),
|
||||
(r"(?i)curl[^\n]*(?:\s-k\b|--insecure)|verify_tls\s*[:=]\s*false|insecure_skip_verify\s*[:=]\s*true", "TLS bypass", code),
|
||||
(r"(?i)chmod\s+0?[0-7][0-7][4-7]\s+[^\n]*(?:\.key|secret|provision|auth\.yaml|users\.yaml)", "world-readable secret", code),
|
||||
(r"(?m)^\s*(?:sudo\s+)?nginx\s+-T\b", "raw nginx capture", code),
|
||||
(r"(?m)^\s*(?:sudo\s+)?(?:diff\b|git\s+diff\b)", "raw diff capture", code),
|
||||
(r"(?i)docker\s+compose[^\n]*\bdwh-auth\b", "DWH service Compose coupling", code),
|
||||
(r"auth-canonical[^\n]*:/run/thothii-auth", "canonical auth root mounted into core", code),
|
||||
(r"(?:useradd|groupadd)[^\n]*10001", "host runtime identity creation", code),
|
||||
(r"(?im)^\s*(?:sudo\s+)?(?:vi|vim|nano|sed|cp|mv|rm|tee)\b[^\n]*(?:CURRENT|generations)", "direct auth projection edit", code),
|
||||
]:
|
||||
if re.search(pattern, source):
|
||||
raise ValueError(f"forbidden {label}")
|
||||
if mode == "auth":
|
||||
architecture = sources["docs/architecture/authentication.md"]
|
||||
user_row = "| `user` | `session.use` |"
|
||||
admin_row = "| `admin` | " + ", ".join(f"`{p}`" for p in ADMIN) + " |"
|
||||
if user_row not in architecture or admin_row not in architecture:
|
||||
raise ValueError("role-to-permission map is not exact")
|
||||
section = architecture.split("## Diagnostics and ordering", 1)[1].split("\n## ", 1)[0]
|
||||
block = re.search(r"```text\n([\s\S]*?)```", section)
|
||||
if not block or block.group(1).split() != CODES:
|
||||
raise ValueError("diagnostic code union is not exact")
|
||||
if re.search(r"(?:extra|unmapped|additional) groups[^.\n]*(?:generate|emit|produce) (?:warnings|alerts)", corpus, re.I):
|
||||
raise ValueError("misleading noise claim for unmapped groups")
|
||||
print(f"{mode} documentation contract passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("mode", choices=["auth", "dwh"])
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
verify(args.root, args.mode)
|
||||
except (ValueError, OSError) as exc:
|
||||
parser.exit(1, f"{args.mode} docs: {exc}\n")
|
||||
@@ -1,135 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Current public guides and internal authentication contracts; no retired runbooks.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
if [[ $# -gt 0 ]]; then
|
||||
[[ $# -eq 2 && $1 == --root && -d $2 ]] || { echo 'usage: verify-dwh-auth-docs.sh [--root DIRECTORY]' >&2; exit 2; }
|
||||
root=$(cd "$2" && pwd -P)
|
||||
fi
|
||||
|
||||
python3 - "$root" <<'PY'
|
||||
import pathlib, re, sys
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
docs = {
|
||||
"server": "docs/install/dwh-auth-server.md",
|
||||
"client": "docs/install/dwh-auth-client-enrollment.md",
|
||||
"tls": "docs/install/dwh-auth-tls.md",
|
||||
"rollout": "docs/operations/psd-dwh-auth-rollout.md",
|
||||
"manual": "docs/testing/dwh-auth-manual-acceptance.md",
|
||||
"evidence": "docs/testing/evidence/psd-dwh-auth-rollout-report-template.md",
|
||||
"local": "docs/install/local-workspace-registry.md",
|
||||
"server_registry": "docs/install/server-workspace-registry.md",
|
||||
"psd": "docs/install/psd-workspace-setup.md",
|
||||
"guide": "docs/guida-utente.md",
|
||||
"index": "docs/index.md",
|
||||
"nav": "mkdocs.yml",
|
||||
"psd_template": "deploy/psd/workspace-bindings.env.example",
|
||||
}
|
||||
text = {}
|
||||
for label, relative in docs.items():
|
||||
path = root / relative
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"dwh-auth docs: missing {relative}")
|
||||
text[label] = path.read_text(encoding="utf-8")
|
||||
|
||||
requirements = {
|
||||
"server": ["key_output=/root/dwh-auth-provision/psd-mac-primary.key", "legacy_key_id=legacy-shared", "v1_header_file", "legacy_header_file", "random_header_file", "--header \"@", "registry_staging", "registry_previous", "registry_restore_rollback", "journal_actual_key_scan=PASS", "sys.argv[2:]", "read_bytes()", "subprocess.Popen", "stderr=subprocess.DEVNULL", "returncode != 0", "max_journal_bytes = 1_048_576", "max_journal_lines = 10_000", "process = None", "except OSError:", "stdout=subprocess.PIPE", "max_chunk_bytes = 65_536", "process.stdout.read1(", "remaining = max_journal_bytes - bytes_seen", "searchable = carry + chunk", "max_needle_length", "process.kill()", "process.wait()", "socket_v1=PASS", "socket_legacy=PASS", "https_v1_post_revoke=PASS", "https_legacy_post_revoke=PASS", "key_file_bytes=PASS", "dd if=\"$1\" bs=65536 status=none", "manifest", "journalctl", "systemctl disable --now", "trap", "/var/lib/dwh-auth", "root:dwh-auth", "2750", ".writer.lock", "0640", "/run/dwh-auth/verify.sock", "0660", "systemd", "key create", "key list", "key status", "key revoke", "check", "backup", "rollback", "disinstallazione", "rest_api", "postgres_direct", "ssh_tunnel"],
|
||||
"client": ["Workspace management", "Validate workspace source", "Test workspace connections", "Save entered secrets", "Forget stored value", "API_KEY_FILE", "THT_WS_PSD_CLINICAL_DWH_API_KEY_FILE", "TLS_CA_FILE", "/rpc/ping", "rest_api", "postgres_direct", "ssh_tunnel", "401", "503", "rotazione", "revoca"],
|
||||
"tls": ["self-issued", ".it", ".com", "SAN", "TLS_CA_FILE", "openssl x509 -noout -fingerprint -sha256", "fuori banda", "rinnovo", "curl -k"],
|
||||
"rollout": ["v1=2xx", "legacy=2xx", "v1=2xx post-revoca", "legacy=401 post-revoca", "file header curl protetti 0600", "PASS/FAIL", "no raw diff", "Task 9", "Task 10", "IN_DISCUSSION", "postgres_direct", "rest_api", "legacy-shared", "nginx -t", "204", "401", "503", "Qdrant", "Ollama", "rollback"],
|
||||
"manual": ["credenziali reali", "sintetici", "/rpc/ping", "204", "401", "503", "TLS", "registry", "postgres_direct", "ssh_tunnel"],
|
||||
"evidence": ["ID pubblici", "owner", "mode", "timestamp", "checksum", "approvazione"],
|
||||
}
|
||||
for label, tokens in requirements.items():
|
||||
lowered = text[label].lower()
|
||||
for token in tokens:
|
||||
if token.lower() not in lowered:
|
||||
raise SystemExit(f"dwh-auth docs: {docs[label]} lacks required topic: {token}")
|
||||
|
||||
for path in [root / docs[k] for k in ("server", "client", "tls", "rollout", "manual", "evidence", "local", "server_registry", "psd", "guide", "index")]:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for target in re.findall(r"(?<!!)\[[^]]*\]\(([^)#]+)(?:#[^)]+)?\)", source):
|
||||
if "://" in target or target.startswith("mailto:"):
|
||||
continue
|
||||
candidate = (path.parent / target).resolve()
|
||||
if not candidate.is_file() or root.resolve() not in candidate.parents:
|
||||
raise SystemExit(f"dwh-auth docs: broken local link {path.relative_to(root)} -> {target}")
|
||||
|
||||
nav = text["nav"]
|
||||
for relative in (docs["server"], docs["client"], docs["tls"], docs["rollout"], docs["manual"], docs["evidence"]):
|
||||
nav_relative = relative.removeprefix("docs/")
|
||||
if nav.count(nav_relative) != 1:
|
||||
raise SystemExit(f"dwh-auth docs: navigation must include once: {nav_relative}")
|
||||
|
||||
corpus = "\n".join(text.values())
|
||||
for pattern, label in [
|
||||
(r"thtdwh_v1\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{43}", "credential literal"),
|
||||
(r"(?mi)^\s*[\"']?secret_sha256[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9_-]{16,}", "credential digest literal"),
|
||||
(r"(?mi)^\s*(?:export\s+)?[A-Z][A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD)\s*=\s*(?!/|<)[^\s#]+", "secret in environment"),
|
||||
(r"(?i)(?:curl|dwh-auth)[^\n]{0,240}(?:(?:-H|--header)\s+['\"][^'\"]*X-API-Key\s*:|--(?:api-key|token|password)\b)", "secret in argv"),
|
||||
(r"(?im)^(?!.*(?:non usare|mai usare)).*curl\s+(?:[^\n]*\s)?(?:-k|--insecure)\b|verify_tls\s*=\s*false|insecure_skip_verify", "TLS bypass"),
|
||||
(r"(?i)chmod\s+0?[0-7][0-7][4-7]\s+[^\n]*(?:\.key|secret|provision)", "world-readable secret"),
|
||||
(r"(?m)^\s*(?:sudo\s+)?nginx\s+-T\b", "raw Nginx capture"),
|
||||
(r"(?m)^\s*(?:sudo\s+)?(?:diff\b|git\s+diff\b)", "raw diff capture"),
|
||||
(r"(?m)^\s*(?:sudo\s+)?curl[^\n]*--config\b", "curl config artifact"),
|
||||
(r"(?i)docker\s+compose[^\n]*\bdwh-auth\b", "Compose coupling"),
|
||||
]:
|
||||
if re.search(pattern, corpus):
|
||||
raise SystemExit(f"dwh-auth docs: forbidden {label}")
|
||||
|
||||
server_direct = re.search(r"server PSD[^\n]{0,100}postgres_direct", text["rollout"], re.I)
|
||||
psd_direct = re.search(r"server PSD[^\n]{0,100}postgres_direct", text["psd"], re.I)
|
||||
if not server_direct or not psd_direct:
|
||||
raise SystemExit("dwh-auth docs: PSD server must remain postgres_direct")
|
||||
template = text["psd_template"]
|
||||
if "tht_ws_psd_clinical_dwh_transport=rest_api" not in template.lower() or "mac/local/remota" not in template.lower() or not re.search(r"mai .*server psd", template, re.I):
|
||||
raise SystemExit("dwh-auth docs: PSD REST template must be explicitly Mac/local/remota, never server PSD")
|
||||
if "TLS_CA_FILE" not in text["psd"] or re.search(r"(?i)nessuna CA|HTTPS pubblico", text["psd"]):
|
||||
raise SystemExit("dwh-auth docs: PSD setup contradicts private CA TLS requirement")
|
||||
|
||||
restore_steps = (
|
||||
'registry_staging="/var/lib/.dwh-auth-restore-$run_id"',
|
||||
'registry_candidate="$registry_staging/dwh-auth"',
|
||||
'sudo tar --acls --xattrs -C "$registry_staging" -xf "$registry_backup"',
|
||||
'if ! sudo /usr/local/sbin/dwh-auth --registry-root "$registry_candidate" check; then',
|
||||
'if ! sudo mv -T -- "$registry_root" "$registry_previous"; then',
|
||||
'if ! sudo mv -T -- "$registry_candidate" "$registry_root"; then',
|
||||
'if ! sudo mv -T -- "$registry_previous" "$registry_root"; then',
|
||||
'if ! sudo mv -T -- "$registry_root" "$registry_staging/failed-dwh-auth"; then',
|
||||
)
|
||||
restore_positions = [text["server"].find(step) for step in restore_steps]
|
||||
if any(position < 0 for position in restore_positions) or restore_positions != sorted(restore_positions):
|
||||
raise SystemExit("dwh-auth docs: restore must stage/check then use guarded same-filesystem renames")
|
||||
if text["server"].count('if ! sudo mv -T -- "$registry_previous" "$registry_root"; then') != 2:
|
||||
raise SystemExit("dwh-auth docs: every restore rollback rename must use guarded mv -T --")
|
||||
if re.search(r"sudo tar[^\n]*-C /var/lib[^\n]*(?:-x|--extract)[^\n]*registry_backup", text["server"]):
|
||||
raise SystemExit("dwh-auth docs: restore must not overlay the live registry")
|
||||
|
||||
|
||||
journal_steps = (
|
||||
"subprocess.Popen(",
|
||||
"[\"journalctl\", \"-u\", \"dwh-auth\", \"--since\", sys.argv[1]",
|
||||
"sys.argv[2:]",
|
||||
"stderr=subprocess.DEVNULL",
|
||||
"stdout=subprocess.PIPE",
|
||||
"max_journal_bytes = 1_048_576",
|
||||
"max_journal_lines = 10_000",
|
||||
"process = None",
|
||||
"except OSError:",
|
||||
"max_chunk_bytes = 65_536",
|
||||
"process.stdout.read1(",
|
||||
"remaining = max_journal_bytes - bytes_seen",
|
||||
"searchable = carry + chunk",
|
||||
"max_needle_length",
|
||||
"process.kill()",
|
||||
"if process is not None:",
|
||||
"process.wait()",
|
||||
"if process.returncode != 0:",
|
||||
"any(needle in searchable for needle in needles)",
|
||||
)
|
||||
if any(step not in text["server"] for step in journal_steps):
|
||||
raise SystemExit("dwh-auth docs: journal scan must fail closed and check the actual key bytes")
|
||||
|
||||
print("dwh-auth documentation contract passed")
|
||||
PY
|
||||
exec python3 "$root/scripts/verify-auth-docs.py" dwh "$@"
|
||||
|
||||
@@ -24,6 +24,7 @@ INTERNAL_PAGES = {
|
||||
"disambiguazione-iniziale.md", "gestione-memory.md", "installazione-docker-4-contesti.md",
|
||||
"install/authentication-upstream.md", "operations/docker-refresh.md",
|
||||
"operations/shell-and-localization.md",
|
||||
"operations/compose-reference.md", "operations/public-docs-publication.md",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user