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]>
119 lines
6.0 KiB
Python
119 lines
6.0 KiB
Python
"""The directory additions and the ad-reconcile job against the CI Samba domain controller (deploy/samba).
|
|
|
|
Skipped unless the TEST_LDAP_* values are set, like test_ldap_dc.py. The seed leaves PS-14-Electrical-Lead out,
|
|
adds PS-Extra-Legacy, nests Shop-Welders-Nested into PS-10-WeldAssembly-Worker, disables disabled.dan, gives
|
|
dup.dave and dup.dina the same employeeID and noemp.nora none. Nothing here writes to the directory.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pshelpers import app_conn, make_settings, needs_pg
|
|
from test_ldap_dc import MISSING, ldap_values
|
|
|
|
from packetstation.auth import directory as userdir
|
|
from packetstation.auth import groups, sessions
|
|
from packetstation.auth.ldap import AdUser, Directory
|
|
from packetstation.settings import Settings
|
|
from packetstation.worker import jobs, runs
|
|
from packetstation.worker.jobs import ad_reconcile
|
|
from packetstation.worker.scheduler import Scheduler
|
|
|
|
pytestmark = pytest.mark.skipif(bool(MISSING), reason=f"no test domain controller ({', '.join(MISSING)} not set)")
|
|
|
|
|
|
@pytest.fixture
|
|
def directory(tmp_path: Path) -> Directory:
|
|
return Directory(make_settings(tmp_path, **ldap_values()))
|
|
|
|
|
|
def test_list_groups_detail(directory: Directory) -> None:
|
|
detail = directory.list_groups_detail()
|
|
names = {n.lower() for n, _ in detail}
|
|
assert "ps-14-electrical-lead" not in names and "ps-extra-legacy" in names
|
|
assert sum(1 for n in groups.group_names() if n.lower() in names) == 49
|
|
base = directory.settings.ldap_group_base_dn.lower()
|
|
assert all(dn.lower().endswith(base) and dn.lower().startswith(f"cn={n.lower()},") for n, dn in detail)
|
|
|
|
|
|
def test_members_of_dn_nested_and_disabled(directory: Directory) -> None:
|
|
dn = dict(directory.list_groups_detail())
|
|
assert {u.sam_account for u in directory.members_of_dn(dn["PS-10-WeldAssembly-Worker"])} == {"nested.ned"}
|
|
weld = {u.sam_account: u for u in directory.members_of_dn(dn["PS-03-WeldParts-Worker"])}
|
|
assert {"weld.andy", "both.bo", "dup.dave", "disabled.dan"} <= set(weld)
|
|
assert weld["disabled.dan"].disabled is True and weld["weld.andy"].disabled is False
|
|
assert directory.members_of_dn("CN=PS-No-Such-Group," + directory.settings.ldap_group_base_dn) == []
|
|
|
|
|
|
def test_search_groups_and_users_by_guid(directory: Directory) -> None:
|
|
found = directory.search_groups("Welders-Nest")
|
|
assert [g["name"] for g in found] == ["Shop-Welders-Nested"]
|
|
assert found[0]["description"] and found[0]["dn"].lower().endswith(directory.settings.ldap_base_dn.lower())
|
|
assert len(directory.search_groups("PS-", limit=3)) == 3
|
|
ann = directory.find_user("admin.ann")
|
|
assert ann is not None
|
|
ghost = uuid.uuid4()
|
|
got = directory.users_by_guid([ann.guid, ghost])
|
|
assert got[ghost] is None and got[ann.guid] is not None and got[ann.guid].sam_account == "admin.ann"
|
|
|
|
|
|
def _run(settings: Settings, d: Directory) -> dict[str, Any]:
|
|
sch = Scheduler(settings, build="pytest", jobs={"ad-reconcile": ad_reconcile.SPEC})
|
|
sch.stack.cache["directory"] = d
|
|
return runs.run_job(sch, "ad-reconcile", "schedule")
|
|
|
|
|
|
def _session(settings: Settings, conn: Any, user_id: uuid.UUID) -> uuid.UUID:
|
|
_, row = sessions.create(
|
|
conn, settings, settings.session_key(), user_id=user_id, kind="password", roles=["clerical"], groups=[]
|
|
)
|
|
sid: uuid.UUID = row["id"]
|
|
return sid
|
|
|
|
|
|
@needs_pg
|
|
def test_reconcile_against_dc(pg_settings: Settings, directory: Directory, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
raised: list[tuple[str, dict[str, Any]]] = []
|
|
monkeypatch.setattr(jobs, "raise_event", lambda conn, t, **kw: raised.append((t, kw)))
|
|
monkeypatch.setattr(jobs, "resolve_event", lambda conn, t, **kw: None)
|
|
|
|
row = _run(pg_settings, directory)
|
|
assert row["status"] == "ok", row["errors"]
|
|
c = row["counts"]
|
|
assert c["groups_missing"] == ["PS-14-Electrical-Lead"] and c["groups_extra"] == ["PS-Extra-Legacy"]
|
|
assert c["groups_checked"] == 49 and c["users_seen"] == 16
|
|
assert "noemp.nora" in c["emp_missing"] and c["emp_duplicate"] == ["9001"]
|
|
assert ("group_missing", "PS-14-Electrical-Lead") in [(t, kw["subject"]) for t, kw in raised]
|
|
assert any(t == "ad_reconcile_report" for t, _ in raised)
|
|
|
|
dan = directory.find_user("disabled.dan")
|
|
nick = directory.find_user("norole.nick")
|
|
ned = directory.find_user("nested.ned")
|
|
assert dan is not None and nick is not None and ned is not None
|
|
ghost = AdUser(uuid.uuid4(), "CN=ghost,DC=pstest,DC=local", "ghost.gary", None, "Gary Ghost", "9999", None, False)
|
|
with app_conn(pg_settings) as conn:
|
|
flags = dict(conn.execute("SELECT sam_account, directory_flags FROM users").fetchall())
|
|
assert flags["dup.dave"]["emp_duplicate"] and flags["dup.dina"]["emp_duplicate"]
|
|
assert flags["noemp.nora"]["emp_missing"] and flags["disabled.dan"]["disabled"]
|
|
assert userdir.roles_of(conn, ned.guid) == ["shop:10:worker"]
|
|
assert conn.execute("SELECT active FROM users WHERE id = %s", (dan.guid,)).fetchone()[0] is False
|
|
# A disabled account that kept a session, an account in no group, an account AD no longer has
|
|
userdir.upsert_user(conn, nick, groups=[])
|
|
userdir.upsert_user(conn, ghost, groups=[])
|
|
sid = {u: _session(pg_settings, conn, u) for u in (dan.guid, nick.guid, ghost.guid)}
|
|
|
|
row = _run(pg_settings, directory)
|
|
assert row["status"] == "ok", row["errors"]
|
|
assert row["counts"]["sessions_ended"] == 3 and row["counts"]["deactivated"] == 1 # the ghost
|
|
with app_conn(pg_settings) as conn:
|
|
why = dict(conn.execute("SELECT id, revoked_reason FROM sessions").fetchall())
|
|
assert why[sid[dan.guid]] == "ad_disabled" and why[sid[nick.guid]] == "roles_lost"
|
|
assert why[sid[ghost.guid]] == "ad_gone"
|
|
gary = conn.execute("SELECT active, directory_flags FROM users WHERE id = %s", (ghost.guid,)).fetchone()
|
|
assert gary[0] is False and gary[1]["gone"] is True
|
|
assert conn.execute("SELECT active FROM users WHERE id = %s", (nick.guid,)).fetchone()[0] is True
|