Files
ThothII/docs/install/dwh-auth-server.md
T

18 KiB

dwh-auth: guida server

dwh-auth autentica la route REST /dwh/ con una chiave per installazione. È un componente Linux opzionale e server-side: usa systemd, non tht né Docker Compose, non legge risultati clinici e non si collega a PostgreSQL. La chiave serve solo a rest_api; postgres_direct e ssh_tunnel non la usano.

Prerequisiti e confini

  • Usare un checkout revisionato, Docker per la build e un operatore autorizzato sul server DWH.
  • Una chiave identifica un'installazione, non una persona. L'installation-id è unico, non personale e senza dati clinici.
  • Chiavi, digest, file di consegna e backup restano in file protetti: mai Git, argv, variabili d'ambiente, log, JSON pubblico o evidenze.
  • Preparare backup e rollback prima di Nginx. Installare il servizio non autorizza una modifica della route pubblica.

Percorsi, owner e mode

Oggetto Percorso Owner e mode
Binario /usr/local/sbin/dwh-auth root:root, 0755
Unit /etc/systemd/system/dwh-auth.service root:root, 0644
Tmpfiles /usr/lib/tmpfiles.d/dwh-auth.conf root:root, 0644
Registro, active, revoked /var/lib/dwh-auth/ root:dwh-auth, 2750
Lock /var/lib/dwh-auth/.writer.lock root:dwh-auth, 0640
Record /var/lib/dwh-auth/{active,revoked}/<public-key-id>.json root:dwh-auth, 0640
Socket runtime /run/dwh-auth/verify.sock dwh-auth:www-data, 0660
Consegne e backup /root/dwh-auth-provision/ directory root:root 0700, file 0600

Il record conserva un digest interno (secret_sha256) e metadati, mai la chiave in chiaro. Non leggere, stampare, calcolare o mettere quel digest in una prova operativa.

Build, installazione e avvio

Costruire dal commit congelato e registrare solo checksum del binario e SHA sorgente:

cd /srv/thothii/app
bash scripts/build-dwh-auth.sh --output /tmp/dwh-auth-release
sha256sum /tmp/dwh-auth-release/dwh-auth-linux-amd64

Scegliere l'architettura corretta. Il template PSD usa il gruppo Nginx www-data; confermarlo prima dell'installazione su un host diverso.

sudo groupadd --system dwh-auth
sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid dwh-auth dwh-auth
sudo install -o root -g root -m 0755 /tmp/dwh-auth-release/dwh-auth-linux-amd64 /usr/local/sbin/dwh-auth
sudo install -o root -g root -m 0644 deploy/dwh-auth/dwh-auth.service /etc/systemd/system/dwh-auth.service
sudo install -o root -g root -m 0644 deploy/dwh-auth/dwh-auth.tmpfiles.conf /usr/lib/tmpfiles.d/dwh-auth.conf
sudo install -d -o root -g root -m 0700 /root/dwh-auth-provision
sudo systemd-tmpfiles --create /usr/lib/tmpfiles.d/dwh-auth.conf
sudo /usr/local/sbin/dwh-auth --registry-root /var/lib/dwh-auth check
sudo systemd-analyze verify /etc/systemd/system/dwh-auth.service
sudo systemctl daemon-reload
sudo systemctl enable --now dwh-auth
sudo systemctl status dwh-auth --no-pager

Controllare i mode con stat. Il servizio apre il registro in sola lettura e crea solo il socket. Non creare JSON, lock o socket a mano: oggetti insicuri devono fallire chiusi.

Check, elenco e stato

Usare sempre un root assoluto. Questi comandi espongono solo ID pubblici, stato, date e scadenza:

sudo /usr/local/sbin/dwh-auth --registry-root /var/lib/dwh-auth check
sudo /usr/local/sbin/dwh-auth --registry-root /var/lib/dwh-auth key list --json
key_id=public-key-id
sudo /usr/local/sbin/dwh-auth --registry-root /var/lib/dwh-auth key status --key-id "$key_id" --json

Un errore di integrità, permessi, symlink o JSON malformato richiede ripristino da backup protetto, non una correzione manuale del record.

Creazione, consegna, scadenza e revoca

Il comando crea la chiave una volta in un nuovo file assoluto 0600; stdout contiene solo ID pubblico, installazione e percorso. Il file di output non deve esistere.

installation_id=psd-mac-primary
description=operatore-mac-primario
key_output=/root/dwh-auth-provision/psd-mac-primary.key
sudo /usr/local/sbin/dwh-auth --registry-root /var/lib/dwh-auth key create \
  --installation-id "$installation_id" \
  --description "$description" \
  --output "$key_output"

Aggiungere --expires-at "YYYY-MM-DDTHH:MM:SSZ" solo se la policy impone una scadenza; il default è nessuna scadenza. Consegnare il file solo con vault aziendale, secret manager, MDM o trasferimento autenticato ristretto. Mai email, chat, ticket, cat o copia-incolla. Il client conferma ID pubblico e ping, poi il materiale temporaneo viene rimosso secondo policy.

L'import legacy è temporaneo PSD: il file sorgente è già root:root 0600 e non viene mai letto o stampato dall'operatore.

sudo /usr/local/sbin/dwh-auth --registry-root /var/lib/dwh-auth key import \
  --legacy-raw --installation-id legacy-shared \
  --from-file /root/dwh-auth-provision/legacy-shared.key

Per rotare: creare seconda generazione, consegnarla, configurarla e provare /rpc/ping; confermare l'ID pubblico; attendere l'osservazione; poi revocare la precedente e provare nuova=successo, precedente=401.

previous_key_id=public-key-id
revocation_reason=shared-credential-rotation
sudo /usr/local/sbin/dwh-auth --registry-root /var/lib/dwh-auth key revoke \
  --key-id "$previous_key_id" --reason "$revocation_reason"

La revoca non è annullabile e un ID revocato non si ricrea.

Backup, rollback e disinstallazione

Prima di mutare, creare un archivio root-only 0600 del registro e copie protette delle sole configurazioni coinvolte. L'archivio contiene digest, quindi è materiale riservato: custodirlo su storage cifrato approvato; l'evidenza ammessa riporta solo percorso, owner, mode, timestamp e checksum dell'archivio. Il rollback dual-key ripristina la route e il servizio revisionati, esegue nginx -t e fa reload solo autorizzato; non ripristina chiavi revocate, PostgreSQL, sessioni legacy, indici Qdrant o cache Ollama.

La disinstallazione richiede autorizzazione esplicita, client REST migrati/revocati e rollback non più necessario. Solo allora disabilitare l'unità; conservare registro e backup fino alla retention approvata. Non inserire dwh-auth in Compose o in tht start/tht stop.

Procedure riproducibili e secret-safe

Eseguire soltanto nel gate autorizzato. Le variabili seguenti contengono percorsi, timestamp e codici, mai una chiave. Il manifest e l'archivio del registro sono 0600; l'archivio resta materiale riservato su storage cifrato approvato.

run_id=$(date -u +%Y%m%dT%H%M%SZ)
backup_root=/root/dwh-auth-provision
registry_root=/var/lib/dwh-auth
registry_backup="$backup_root/registry-$run_id.tar"
manifest="$backup_root/registry-$run_id.manifest"
sudo install -o root -g root -m 0600 /dev/null "$registry_backup"
sudo install -o root -g root -m 0600 /dev/null "$manifest"
sudo tar --acls --xattrs -C /var/lib -cf "$registry_backup" dwh-auth
sudo sh -c 'sha256sum "$1" > "$2"' sh "$registry_backup" "$manifest"
if sudo sha256sum -c "$manifest" >/dev/null; then printf 'registry_manifest=PASS\n'; else printf 'registry_manifest=FAIL\n' >&2; exit 1; fi

Il ripristino non sovrappone mai un tar al registro attivo. Estrarre prima in staging nello stesso filesystem di /var/lib, verificare il candidato, rinominare il registro attuale in una copia recuperabile e sostituirlo. Non cancellare il pre-ripristino: serve al rollback se check o l'avvio falliscono.

registry_staging="/var/lib/.dwh-auth-restore-$run_id"
registry_candidate="$registry_staging/dwh-auth"
registry_previous="/var/lib/dwh-auth.pre-restore-$run_id"
if [ -e "$registry_staging" ] || [ -e "$registry_previous" ]; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi
if ! sudo sha256sum -c "$manifest" >/dev/null; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi
if ! sudo install -d -o root -g root -m 0700 "$registry_staging"; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi
if ! sudo tar --acls --xattrs -C "$registry_staging" -xf "$registry_backup"; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi
if ! sudo /usr/local/sbin/dwh-auth --registry-root "$registry_candidate" check; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi
if ! sudo systemctl stop dwh-auth; then printf 'registry_restore=FAIL\n' >&2; exit 1; fi
if ! sudo mv -T -- "$registry_root" "$registry_previous"; then
  if sudo systemctl start dwh-auth; then printf 'registry_restore_rollback=PASS\n' >&2; else printf 'registry_restore_rollback=FAIL\n' >&2; fi
  exit 1
fi
if ! sudo mv -T -- "$registry_candidate" "$registry_root"; then
  if ! sudo mv -T -- "$registry_previous" "$registry_root"; then printf 'registry_restore_rollback=FAIL\n' >&2; exit 1; fi
  if sudo /usr/local/sbin/dwh-auth --registry-root "$registry_root" check && sudo systemctl start dwh-auth; then printf 'registry_restore_rollback=PASS\n' >&2; else printf 'registry_restore_rollback=FAIL\n' >&2; fi
  exit 1
fi
if sudo /usr/local/sbin/dwh-auth --registry-root "$registry_root" check && sudo systemctl start dwh-auth; then
  printf 'registry_restore=PASS\n'
else
  sudo systemctl stop dwh-auth || true
  if ! sudo mv -T -- "$registry_root" "$registry_staging/failed-dwh-auth"; then printf 'registry_restore_rollback=FAIL\n' >&2; exit 1; fi
  if ! sudo mv -T -- "$registry_previous" "$registry_root"; then printf 'registry_restore_rollback=FAIL\n' >&2; exit 1; fi
  if sudo /usr/local/sbin/dwh-auth --registry-root "$registry_root" check && sudo systemctl start dwh-auth; then printf 'registry_restore_rollback=PASS\n' >&2; else printf 'registry_restore_rollback=FAIL\n' >&2; fi
  exit 1
fi

Per le prove, creare file header 0600 che contengono esattamente X-API-Key: valore. Il valore passa dal file chiave al file header senza argv, ambiente o stdout. I file header sono materiale segreto con la stessa custodia e retention delle chiavi.

v1_key_file="$key_output"
legacy_key_file=/root/dwh-auth-provision/legacy-shared.key
v1_header_file=/root/dwh-auth-provision/dwh-auth-v1.header
legacy_header_file=/root/dwh-auth-provision/dwh-auth-legacy.header
random_header_file=/root/dwh-auth-provision/dwh-auth-random.header
if ! sudo python3 -c '
import pathlib, sys
if any(b"\n" in pathlib.Path(path).read_bytes() for path in sys.argv[1:]):
    raise SystemExit(1)
' "$v1_key_file" "$legacy_key_file"; then
  printf 'key_file_bytes=FAIL\n' >&2
  exit 1
fi
printf 'key_file_bytes=PASS\n'
for header_file in "$v1_header_file" "$legacy_header_file" "$random_header_file"; do
  sudo install -o root -g root -m 0600 /dev/null "$header_file"
done
sudo sh -c '{ printf "%s" "X-API-Key: "; dd if="$1" bs=65536 status=none; printf "\n"; } > "$2"' sh "$v1_key_file" "$v1_header_file"
sudo sh -c '{ printf "%s" "X-API-Key: "; dd if="$1" bs=65536 status=none; printf "\n"; } > "$2"' sh "$legacy_key_file" "$legacy_header_file"
sudo sh -c 'printf "%s\n" "X-API-Key: invalid-test" > "$1"' sh "$random_header_file"

Il socket /verify deve restituire 204 per v1 e legacy durante il dual-key, 401 per file casuale e richiesta senza header. Stampare solo PASS/FAIL.

status=$(sudo curl --header "@$v1_header_file" --unix-socket /run/dwh-auth/verify.sock --output /dev/null --silent --show-error --write-out '%{http_code}' http://localhost/verify)
[ "$status" = 204 ] && printf 'socket_v1=PASS\n' || { printf 'socket_v1=FAIL\n' >&2; exit 1; }
status=$(sudo curl --header "@$legacy_header_file" --unix-socket /run/dwh-auth/verify.sock --output /dev/null --silent --show-error --write-out '%{http_code}' http://localhost/verify)
[ "$status" = 204 ] && printf 'socket_legacy=PASS\n' || { printf 'socket_legacy=FAIL\n' >&2; exit 1; }
status=$(sudo curl --header "@$random_header_file" --unix-socket /run/dwh-auth/verify.sock --output /dev/null --silent --show-error --write-out '%{http_code}' http://localhost/verify)
[ "$status" = 401 ] && printf 'socket_random=PASS\n' || { printf 'socket_random=FAIL\n' >&2; exit 1; }
status=$(sudo curl --unix-socket /run/dwh-auth/verify.sock --output /dev/null --silent --show-error --write-out '%{http_code}' http://localhost/verify)
[ "$status" = 401 ] && printf 'socket_missing=PASS\n' || { printf 'socket_missing=FAIL\n' >&2; exit 1; }

Per HTTPS reale usare i file header protetti e la CA approvata contro /dwh/rpc/ping: PostgREST può restituire qualsiasi 2xx, non si pretende 204. Prima della revoca, v1 e legacy devono dare 2xx; il file casuale deve dare 401.

ping_url=https://supabase-aritmolab.policlinicosandonato.it/dwh/rpc/ping
ca_file=/root/dwh-auth-provision/psd-dwh-ca.pem
status=$(sudo curl --header "@$v1_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url")
case "$status" in 2??) printf 'https_v1_pre_revoke=PASS\n' ;; *) printf 'https_v1_pre_revoke=FAIL\n' >&2; exit 1 ;; esac
status=$(sudo curl --header "@$legacy_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url")
case "$status" in 2??) printf 'https_legacy_pre_revoke=PASS\n' ;; *) printf 'https_legacy_pre_revoke=FAIL\n' >&2; exit 1 ;; esac
status=$(sudo curl --header "@$random_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url")
[ "$status" = 401 ] && printf 'https_random=PASS\n' || { printf 'https_random=FAIL\n' >&2; exit 1; }

Dopo l'osservazione, revocare solo la legacy usando il suo ID pubblico già registrato. Dopo la revoca v1 resta 2xx e legacy diventa 401 anche via HTTPS.

legacy_key_id=legacy-shared
sudo /usr/local/sbin/dwh-auth --registry-root "$registry_root" key revoke --key-id "$legacy_key_id" --reason shared-credential-rotation
status=$(sudo curl --header "@$v1_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url")
case "$status" in 2??) printf 'https_v1_post_revoke=PASS\n' ;; *) printf 'https_v1_post_revoke=FAIL\n' >&2; exit 1 ;; esac
status=$(sudo curl --header "@$legacy_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url")
[ "$status" = 401 ] && printf 'https_legacy_post_revoke=PASS\n' || { printf 'https_legacy_post_revoke=FAIL\n' >&2; exit 1; }

Per provare 503 in una finestra approvata, registrare l'orario, fermare temporaneamente l'unità, eseguire il ping con timeout e trap di ripristino; il comando deve stampare solo PASS/FAIL.

was_active=$(sudo systemctl is-active dwh-auth || true)
[ "$was_active" = active ] || { printf 'https_auth_down=FAIL\n' >&2; exit 1; }
restore_auth() { sudo systemctl start dwh-auth; }
trap restore_auth EXIT INT TERM
sudo systemctl stop dwh-auth
status=$(sudo curl --header "@$random_header_file" --cacert "$ca_file" --connect-timeout 5 --max-time 15 --output /dev/null --silent --show-error --write-out '%{http_code}' "$ping_url" || true)
[ "$status" = 503 ] && printf 'https_auth_down=PASS\n' || { printf 'https_auth_down=FAIL\n' >&2; exit 1; }
sudo systemctl start dwh-auth
trap - EXIT INT TERM

Lo scan journal non salva righe grezze: controlla davvero le chiavi v1 e legacy leggendo solo i percorsi dei file da argv, e conserva anche la difesa generica per prefisso e digest. Il filtro emette solo PASS/FAIL.

since=$(date -u -d '15 minutes ago' +%Y-%m-%dT%H:%M:%SZ)
if sudo python3 -c '
import pathlib, subprocess, sys
max_journal_bytes = 1_048_576
max_journal_lines = 10_000
max_chunk_bytes = 65_536
process = None
try:
    actual_keys = {pathlib.Path(path).read_bytes() for path in sys.argv[2:]}
    needles = (b"thtdwh_v1", b"secret_sha256", *actual_keys)
    max_needle_length = max(map(len, needles))
    process = subprocess.Popen(
        ["journalctl", "-u", "dwh-auth", "--since", sys.argv[1], "--no-pager", "--output=cat"],
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
    )
except OSError:
    raise SystemExit(2)
def stop_child():
    if process is not None:
        if process.poll() is None:
            process.kill()
        process.wait()
bytes_seen = 0
line_count = 0
line_open = False
carry = b""
try:
    while True:
        remaining = max_journal_bytes - bytes_seen
        if remaining == 0:
            if process.stdout.read1(1):
                raise SystemExit(2)
            break
        chunk = process.stdout.read1(min(max_chunk_bytes, remaining))
        if not chunk:
            break
        bytes_seen += len(chunk)
        searchable = carry + chunk
        if any(needle in searchable for needle in needles):
            raise SystemExit(1)
        carry = searchable[-(max_needle_length - 1):]
        for byte in chunk:
            if byte == 10:
                line_count += 1
                line_open = False
                if line_count > max_journal_lines:
                    raise SystemExit(2)
            else:
                line_open = True
    if line_open:
        line_count += 1
        if line_count > max_journal_lines:
            raise SystemExit(2)
finally:
    stop_child()
if process.returncode != 0:
    raise SystemExit(2)
' "$since" "$v1_key_file" "$legacy_key_file"; then
  printf 'journal_actual_key_scan=PASS\n'
else
  printf 'journal_actual_key_scan=FAIL\n' >&2
  exit 1
fi

Dopo rollback verificato e migrazione/revoca di ogni client REST, la disinstallazione resta condizionata all'approvazione: eseguire sudo systemctl disable --now dwh-auth, ma mantenere registro, backup, manifest e file header protetti per la retention; non cancellarli durante il rollback.

Troubleshooting

Sintomo Interpretazione e azione
401 Chiave assente, malformata, sconosciuta, scaduta, revocata o errata. Verificare trasporto, ID pubblico e consegna; non cercare dettagli nel messaggio.
503 Servizio, socket o registro non disponibile/sicuro. Controllare systemctl, socket, mode e check; ripristinare il backup approvato.
check fallisce Integrità del registro non valida. Fermare le scritture, preservare stato e ripristinare; non editare JSON.
TLS fallisce CA o SAN non validi. Seguire TLS, senza bypass.

Per il rollout PSD con i due gate separati vedere il runbook PSD.