Phase 9: Admin completion, reconcile, notifications, integrity, backups
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

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]>
This commit is contained in:
2026-10-21 18:27:09 -06:00
co-authored by Claude Opus 5.5
parent ce28d2f3c6
commit a82e056b6a
116 changed files with 15125 additions and 297 deletions
+172
View File
@@ -0,0 +1,172 @@
"""The security settings overlay: bounds, compare-and-set, and the effect on the rate limiter, the idle lock, the
session length and the security alerts."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import app_conn, make_settings, needs_pg, sign_in
from packetstation.auth import secsettings
from packetstation.settings import Settings
@pytest.fixture
def raised(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
"""Record notify.raise_event calls instead of writing events (agent A's module is tested on its own)."""
from packetstation import notify
calls: list[dict[str, Any]] = []
def fake(conn: Any, event_type: str, **kw: Any) -> int:
calls.append({"event_type": event_type, **kw})
return len(calls)
monkeypatch.setattr(notify, "raise_event", fake)
return calls
# ---------------------------------------------------------------- pure
def test_validate_bounds_and_unknown_keys() -> None:
assert secsettings.validate({"session_idle_minutes": 60, "signin_account_cooldown_minutes": 0}) == {
"session_idle_minutes": 60,
"signin_account_cooldown_minutes": 0,
}
with pytest.raises(secsettings.SecSettingsError) as e:
secsettings.validate(
{
"session_idle_minutes": 61,
"signin_account_limit": 16,
"signin_ip_limit": "50",
"roles_refresh_minutes": True,
"pin_max_failures": 3,
}
)
assert len(e.value.errors) == 5
assert "session_idle_minutes: a whole number from 1 to 60" in e.value.errors
assert "pin_max_failures: not a security setting" in e.value.errors
# Always under the domain's lockout at 20
assert secsettings.BOUNDS["signin_account_limit"][1] < 20
def test_stored_values_drop_bad_keys_and_defaults_come_from_env(tmp_path: Path) -> None:
assert secsettings.stored_values({"session_lock_minutes": 5, "session_idle_minutes": 0, "x": 1}) == {
"session_lock_minutes": 5
}
assert secsettings.stored_values(["not", "a", "dict"]) == {}
s = make_settings(tmp_path, session_idle_minutes=20, signin_ip_limit=40)
d = secsettings.defaults(s)
assert d["session_idle_minutes"] == 20 and d["signin_ip_limit"] == 40 and d["signin_burst_threshold"] == 20
assert set(d) == set(secsettings.BOUNDS)
# ---------------------------------------------------------------- database
pg = needs_pg
@pg
def test_settings_api_bounds_cas_and_effect(
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, raised: list[dict[str, Any]]
) -> None:
sign_in(pg_client, "admin.ann")
got = pg_client.get("/api/admin/security/settings").json()
assert got["version"] == 0 and got["values"] == got["defaults"]
assert got["values"]["signin_account_limit"] == 5 and got["bounds"]["signin_account_limit"] == [1, 15]
assert set(got["bounds"]) == set(secsettings.BOUNDS)
r = pg_client.put("/api/admin/security/settings", json={"values": {"session_lock_minutes": 31}, "version": 0})
assert r.status_code == 422 and r.json()["code"] == "bad_settings"
assert r.json()["errors"] == ["session_lock_minutes: a whole number from 1 to 30"]
r = pg_client.put("/api/admin/security/settings", json={"values": {"nope": 3}, "version": 0})
assert r.status_code == 422
r = pg_client.put(
"/api/admin/security/settings",
json={"values": {"signin_account_limit": 2, "session_lock_minutes": 1, "session_pin_hours": 2}, "version": 0},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["version"] == 1 and body["values"]["signin_account_limit"] == 2
assert body["values"]["session_idle_minutes"] == body["defaults"]["session_idle_minutes"]
stale = pg_client.put("/api/admin/security/settings", json={"values": {"signin_ip_limit": 50}, "version": 0})
assert stale.status_code == 409 and stale.json()["code"] == "version_conflict"
assert stale.json()["current"]["version"] == 1
with app_conn(pg_settings) as conn:
row = conn.execute(
"SELECT detail FROM audit_events WHERE action = 'admin.security_settings_updated'"
).fetchone()[0]
assert row["changed"]["signin_account_limit"] == {"from": 5, "to": 2}
assert row["before"]["session_lock_minutes"] == 3 and row["after"]["session_lock_minutes"] == 1
eff = secsettings.effective(conn, pg_settings)
assert eff.signin_account_limit == 2 and eff.version == 1 and eff.settings is not None
assert eff.settings.signin_account_limit == 2 and pg_settings.signin_account_limit == 5
# An overlaid copy passed again comes back as it is
assert secsettings.overlay(conn, eff.settings) is eff.settings
# The rate limiter: two failures now start the cooldown (the environment says five)
pg_client.post("/api/auth/signout")
assert sign_in(pg_client, "clerk.clara", "wrong").status_code == 401
assert sign_in(pg_client, "clerk.clara", "wrong").status_code == 401
r = sign_in(pg_client, "clerk.clara")
assert r.status_code == 429 and r.json()["code"] == "rate_limited"
assert [c["event_type"] for c in raised] == ["security_alert"]
assert raised[0]["subject"] == "account:clerk.clara" and raised[0]["dedupe_minutes"] == 15
from packetstation.notify import EVENT_TYPES
assert "security_alert" in EVENT_TYPES and "signin_burst" in EVENT_TYPES
with app_conn(pg_settings) as conn:
lock = conn.execute("SELECT subject_id, detail FROM audit_events WHERE action = 'auth.lockout'").fetchall()
assert len(lock) == 1 and lock[0][0] == "account:clerk.clara" and lock[0][1]["minutes"] == 10
# The idle lock: one minute now (the environment says three); PIN sessions last two hours
sign_in(pg_client, "wh.wade")
assert pg_client.get("/api/auth/me").json()["session"]["lock_minutes"] == 1
with app_conn(pg_settings) as conn:
conn.execute("UPDATE sessions SET last_seen_at = now() - interval '90 seconds' WHERE revoked_at IS NULL")
r = pg_client.get("/api/account")
assert r.status_code == 423 and r.json()["code"] == "locked"
# The Security tab lists the lockout and the admin change
pg_client.post("/api/auth/signout")
sign_in(pg_client, "admin.ann")
kinds = {a["kind"] for a in pg_client.get("/api/admin/security/alerts").json()}
assert {"lockout", "admin_change"} <= kinds
assert pg_client.get("/api/admin/security/alerts", params={"hours": 169}).status_code == 422
@pg
def test_session_hours_and_burst(
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory, raised: list[dict[str, Any]]
) -> None:
sign_in(pg_client, "admin.ann")
r = pg_client.put(
"/api/admin/security/settings",
json={"values": {"session_password_hours": 2, "signin_burst_threshold": 5}, "version": 0},
)
assert r.status_code == 200, r.text
sign_in(pg_client, "clerk.clara")
with app_conn(pg_settings) as conn:
hours = conn.execute(
"SELECT extract(epoch FROM s.expires_at - s.created_at) / 3600 FROM sessions s JOIN users u"
" ON u.id = s.user_id WHERE u.sam_account = 'clerk.clara'"
).fetchone()[0]
assert round(float(hours)) == 2
# Five failures across five accounts reach the burst threshold
for sam in ("a1", "a2", "a3", "a4", "a5"):
assert sign_in(pg_client, sam, "x").status_code == 401
bursts = [c for c in raised if c["event_type"] == "signin_burst"]
assert len(bursts) == 1 and bursts[0]["subject"] == "burst" and bursts[0]["detail"]["failures"] == 5
assert sign_in(pg_client, "a6", "x").status_code == 401
assert len([c for c in raised if c["event_type"] == "signin_burst"]) == 1 # audited once per 30 minutes
sign_in(pg_client, "admin.ann")
alerts = pg_client.get("/api/admin/security/alerts").json()
assert any(a["kind"] == "signin_burst" and a["detail"]["reason"] == "burst" for a in alerts)