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]>
173 lines
8.0 KiB
Python
173 lines
8.0 KiB
Python
"""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)
|