Files
spencermandClaude Opus 5.5 a82e056b6a
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s
Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must
create), Profiles (welder stamp, station limits, active) with a CSV import
that previews and applies only when clean, Checklists (weld and plain),
Security (session, lockout and rate-limit settings with bounds, alerts for
the last 24 hours, the integrity result), Access log with filters and a
guarded CSV, Who is in, Stamp check, Notifications, and the employeeID
flags. Every Admin change is audited.

Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh
(15 minutes), integrity (seals, asset hashes, missing and orphan files, the
audit chain), backup-check, notify-send and notify-digest. Notifications
route per event type to AD groups, users, mailboxes or the department
Supervisor group, immediately or in a daily digest, over STARTTLS verified
against the PESCO CA, with dry run on the test stack.

Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer
at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21.
CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19.

Migration 0010. Closes the app part of I6, M3, O1, O2 and S3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:27:09 -06:00

276 lines
9.8 KiB
Python

"""The integrity check, nightly at 04:00 site time.
- Seals: every unbroken seal of a filed packet is verified again with the seal key.
- Asset hashes: the sha256 of every file uploaded in the last 7 days, every night, and of every file on Sundays
(site time). Each check sets assets.hash_checked_at and hash_ok.
- Missing files: every asset row whose file is not there.
- Orphan files: files under ASSETS_DIR that no asset row names. Reported only, never deleted. A file younger than
an hour is skipped (an upload in flight).
- The audit hash chain, from the first row.
One integrity_findings row per finding (at most 500 per kind; the counts hold the whole number). Any finding
raises the `integrity_failure` condition; a clean, complete run resolves it.
"""
from __future__ import annotations
import hashlib
import hmac
import os
import re
import time as clock
from datetime import datetime, time
from pathlib import Path
from typing import Any
from psycopg.types.json import Jsonb
from ... import audit
from ...feed.config import FeedSettings
from ...settings import SecretFileError
from .. import jobs
from . import Counts, JobContext, JobSpec, seconds_until
AT = time(4, 0)
NAME = "integrity"
KINDS = ("seal", "asset_hash", "asset_missing", "blob_orphan", "audit_chain")
CAP = 500
RECENT_DAYS = 7
ORPHAN_MIN_AGE_SECONDS = 3600
CHUNK = 1024 * 1024
SKIP_DIRS = {"lost+found"}
SUBJECT = "integrity"
LABELS = {
"seal": "seal does not verify",
"asset_hash": "file changed",
"asset_missing": "file missing",
"blob_orphan": "file with no asset row",
"audit_chain": "audit chain break",
}
class Findings:
"""Collects findings and writes one row each, capped per kind."""
def __init__(self, conn: Any, run_id: int):
self.conn = conn
self.run_id = run_id
self.counts: dict[str, int] = dict.fromkeys(KINDS, 0)
self.samples: dict[str, list[str]] = {k: [] for k in KINDS}
def add(self, kind: str, subject: str, detail: dict[str, Any]) -> None:
self.counts[kind] += 1
if self.counts[kind] > CAP:
return
if len(self.samples[kind]) < 10:
self.samples[kind].append(subject)
self.conn.execute(
"INSERT INTO integrity_findings (run_id, kind, subject, detail) VALUES (%s, %s, %s, %s)",
(self.run_id, kind, subject, Jsonb(detail)),
)
@property
def total(self) -> int:
return sum(self.counts.values())
# ---------------------------------------------------------------- seals
def check_seals(ctx: JobContext, found: Findings) -> int:
"""Verify every unbroken seal of a filed packet. Returns the number checked."""
from ...domain import packets
from ...domain.pipeline import seal_hash, seal_key_id
conn = ctx.conn
rows = conn.execute(
"SELECT s.id, s.packet_id, s.hash, s.key_id FROM packet_seals s JOIN packets p ON p.id = s.packet_id"
" WHERE s.broken_at IS NULL AND p.status = 'filed' ORDER BY s.packet_id"
).fetchall()
if not rows:
return 0
try:
key = ctx.settings.seal_key()
except SecretFileError as e:
ctx.degrade(f"seals not checked: {e}")
return 0
kid = seal_key_id(key)
checked = 0
for seal_id, packet_id, digest, key_id in rows:
detail: dict[str, Any] = {"packet_id": packet_id, "seal_id": seal_id}
if key_id != kid:
found.add("seal", f"packet:{packet_id}", {**detail, "reason": "sealed with another key", "key_id": key_id})
checked += 1
continue
try:
with conn.transaction():
row = packets.load_row(conn, packet_id)
content = packets.seal_content(conn, row) if row is not None else None
except Exception as e: # one packet that cannot be read does not stop the others
ctx.error(f"packet {packet_id}: seal not checked: {type(e).__name__}: {e}")
continue
if content is None:
continue
ok = hmac.compare_digest(seal_hash(key, content), bytes(digest))
if not ok:
found.add("seal", f"packet:{packet_id}", {**detail, "reason": "the packet no longer matches its seal"})
checked += 1
return checked
# ---------------------------------------------------------------- assets
def _path(root: Path, key: str) -> Path | None:
p = (root / key).resolve()
return p if p.is_relative_to(root) else None
def sha256_of(path: Path) -> bytes:
h = hashlib.sha256()
with path.open("rb") as f:
while chunk := f.read(CHUNK):
h.update(chunk)
return h.digest()
def check_assets(ctx: JobContext, found: Findings, *, full: bool) -> dict[str, int]:
"""Missing files for every asset row; the sha256 of the recent ones (all of them when full)."""
conn = ctx.conn
root = Path(ctx.settings.assets_dir).resolve()
rows = conn.execute(
"SELECT id, storage_key, sha256, size, uploaded_at >= now() - make_interval(days => %s) AS recent"
" FROM assets ORDER BY uploaded_at, id",
(RECENT_DAYS,),
).fetchall()
hashed = 0
for aid, key, digest, size, recent in rows:
p = _path(root, str(key))
if p is None or not p.is_file():
found.add("asset_missing", str(aid), {"storage_key": key})
conn.execute("UPDATE assets SET hash_checked_at = now(), hash_ok = false WHERE id = %s", (aid,))
continue
if not (full or recent):
continue
try:
actual = sha256_of(p)
except OSError as e:
found.add("asset_missing", str(aid), {"storage_key": key, "error": e.strerror or type(e).__name__})
conn.execute("UPDATE assets SET hash_checked_at = now(), hash_ok = false WHERE id = %s", (aid,))
continue
ok = hmac.compare_digest(actual, bytes(digest))
hashed += 1
if not ok:
found.add(
"asset_hash",
str(aid),
{"storage_key": key, "expected": bytes(digest).hex(), "actual": actual.hex(), "size": size},
)
conn.execute("UPDATE assets SET hash_checked_at = now(), hash_ok = %s WHERE id = %s", (ok, aid))
return {"assets": len(rows), "hashed": hashed}
def check_orphans(ctx: JobContext, found: Findings) -> int:
"""Files under ASSETS_DIR that no asset row names. Never deletes anything. Returns the files looked at."""
root = Path(ctx.settings.assets_dir)
if not root.is_dir():
ctx.degrade(f"ASSETS_DIR {root} is not a folder")
return 0
keys = {str(r[0]) for r in ctx.conn.execute("SELECT storage_key FROM assets")}
cutoff = clock.time() - ORPHAN_MIN_AGE_SECONDS
seen = 0
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for name in filenames:
p = Path(dirpath) / name
rel = p.relative_to(root).as_posix()
seen += 1
if rel in keys:
continue
try:
st = p.stat()
except OSError:
continue
if st.st_mtime > cutoff:
continue
found.add(
"blob_orphan", rel, {"size": st.st_size, "mtime": datetime.fromtimestamp(st.st_mtime).isoformat()}
)
return seen
# ---------------------------------------------------------------- audit chain
_ROW = re.compile(r"^row (\d+):")
def check_audit(ctx: JobContext, found: Findings) -> int:
ok, n, detail = audit.verify_chain(ctx.conn)
if not ok:
m = _ROW.match(detail)
found.add("audit_chain", m.group(1) if m else "?", {"detail": detail, "rows_checked": n})
return n
# ---------------------------------------------------------------- the run
def _event_text(found: Findings) -> tuple[str, str]:
parts = [f"{n} {LABELS[k]}" for k, n in found.counts.items() if n]
lines = []
for k, n in found.counts.items():
if n:
lines.append(f"{LABELS[k].capitalize()}: {n} ({', '.join(found.samples[k])}{', ...' if n > 10 else ''}).")
lines.append("Admin, Security tab: the full list of findings.")
title = f"Integrity check found {found.total} problem{'s' if found.total != 1 else ''}: " + ", ".join(parts)
return title, "\n".join(lines)
def full_day(now_site: datetime) -> bool:
"""Sunday, site time: every asset is rehashed."""
return now_site.weekday() == 6
def run(ctx: JobContext) -> Counts:
conn = ctx.conn
full = full_day(ctx.now_site())
found = Findings(conn, ctx.run_id)
counts: Counts = {"full": full}
with conn.transaction():
counts["seals"] = check_seals(ctx, found)
with conn.transaction():
counts.update(check_assets(ctx, found, full=full))
with conn.transaction():
counts["files"] = check_orphans(ctx, found)
with conn.transaction():
counts["audit_rows"] = check_audit(ctx, found)
counts["findings"] = dict(found.counts)
counts["total"] = found.total
try:
with conn.transaction():
if found.total:
title, body = _event_text(found)
jobs.raise_event(
conn,
"integrity_failure",
subject=SUBJECT,
title=title,
body=body,
detail={"run_id": ctx.run_id, **found.counts},
severity="critical",
condition=True,
)
elif not ctx.degraded:
jobs.resolve_event(conn, "integrity_failure", subject=SUBJECT, title="Integrity check failure")
except Exception as e:
ctx.log.exception("integrity notification failed")
ctx.error(f"notification: {type(e).__name__}: {e}", degrade=False)
return counts
def cadence(_: FeedSettings, now_site: datetime) -> int:
return seconds_until(now_site, AT)
SPEC = JobSpec(name=NAME, run=run, cadence=cadence, pausable=False)