Files
PacketStation/server/packetstation/auth/directory.py
T
spencermandClaude Fable 5.1 97b0973130 Phase 1: identity, sessions, audit, frontend shell
AD sign-in over LDAPS (search with the service account, bind as the user,
nested groups through LDAP_MATCHING_RULE_IN_CHAIN, disabled accounts refused),
roles as the union of PS-* groups mapped by a table derived from the seeded
departments. Server-side sessions with idle, lock and absolute timeouts, Origin
and X-Requested-With check, step-up unlock. Optional Argon2id PIN set with the
domain password; PIN sign-in by employee ID on registered devices only; five
wrong attempts disable the PIN, three wrong at unlock or signature end the
session. Rate limits per account and per address before any bind. Devices,
signatures table and sign helper, audit rows for every identity event, SSE over
LISTEN/NOTIFY with presence. Account settings and Admin APIs and screens
(AD view, devices, PIN clearing, group map with missing-group flags, refresh
from AD now). CLI ad-groups --list and ad-refresh. Migration 0002.

Frontend: real sign-in (password tab, PIN keypad on registered devices), lock
screen, role switcher, Account settings, Admin shell, screen placeholders.

CI: Samba AD DC container (PSTEST.LOCAL) with a generated CA, 49 of the 50
groups plus one extra, 17 test users; wired into the CI compose overlay and
the Gitea workflow; Playwright sign-in scenario.

Verified on docker-1: 171 server tests green inside the CI stack against
PostgreSQL and the Samba DC, 48 frontend tests, 8 Playwright scenarios against
the live stack (password and PIN sign-in, lock and unlock, Admin group map).
Findings recorded in the docs: the DC needs CAP_SYS_ADMIN, a hidden tab locks
only after a minute as the old build did,
and the app uses the alias ps-app for Playwright because "app" is on
Chromium's HSTS preload list.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 07:40:29 -06:00

248 lines
9.9 KiB
Python

"""The cached directory: `users` and `user_roles` rows written from what AD reports.
Packet Station grants no roles. `user_roles` is a cache of the roles AD groups give, rebuilt at every
sign-in, on the session's roles refresh and by the AD refresh.
"""
from __future__ import annotations
import logging
import uuid
from collections import Counter
from dataclasses import dataclass, field
from typing import Any
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from .. import audit
from . import groups as groupmap
from . import sessions
from .ldap import AdUser, DirectoryProtocol
log = logging.getLogger(__name__)
def upsert_user(conn: Any, u: AdUser, *, groups: list[str], signin: bool = False) -> bool:
"""Insert or refresh the users row for an AD account. True when the row is new.
A different, older row holding the same sAMAccountName (a deleted and re-created AD account) is renamed
and marked inactive so the unique index holds and its history keeps its name.
"""
with conn.transaction():
conn.execute(
"UPDATE users SET sam_account = sam_account || '~' || left(id::text, 8), active = false"
" WHERE lower(sam_account) = lower(%s) AND id <> %s",
(u.sam_account, u.guid),
)
flags = {"disabled": u.disabled, "emp_missing": not u.emp_no, "groups": groups}
row = conn.execute(
"INSERT INTO users (id, sam_account, upn, display_name, emp_no, email, active, directory_flags,"
" ad_synced_at, last_signin_at)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, now(), CASE WHEN %s THEN now() END)"
" ON CONFLICT (id) DO UPDATE SET sam_account = EXCLUDED.sam_account, upn = EXCLUDED.upn,"
" display_name = EXCLUDED.display_name, emp_no = EXCLUDED.emp_no, email = EXCLUDED.email,"
" active = EXCLUDED.active, directory_flags = users.directory_flags || EXCLUDED.directory_flags,"
" ad_synced_at = now(), last_signin_at = coalesce(EXCLUDED.last_signin_at, users.last_signin_at)"
" RETURNING (xmax = 0) AS inserted",
(
u.guid,
u.sam_account,
u.upn,
u.display_name,
u.emp_no,
u.email,
not u.disabled,
Jsonb(flags),
signin,
),
).fetchone()
return bool(row[0])
def set_roles(conn: Any, user_id: Any, roles: list[str]) -> None:
"""Rebuild a user's `user_roles` rows from role strings."""
with conn.transaction():
conn.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
for text in roles:
r = groupmap.parse_role(text)
conn.execute(
"INSERT INTO user_roles (user_id, role, dept_code) VALUES (%s, %s::role_kind, %s)",
(user_id, r.kind, r.dept_code),
)
def roles_of(conn: Any, user_id: Any) -> list[str]:
rows = conn.execute("SELECT role::text, dept_code FROM user_roles WHERE user_id = %s", (user_id,)).fetchall()
return groupmap.current().sort_roles(groupmap.role_from_row(r[0], r[1]) for r in rows)
def get_user(conn: Any, user_id: Any) -> dict[str, Any] | None:
with conn.cursor(row_factory=dict_row) as cur:
row: dict[str, Any] | None = cur.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
return row
def mark_inactive(conn: Any, user_id: Any, *, reason: str, gone: bool) -> None:
conn.execute(
"UPDATE users SET active = false, directory_flags = directory_flags || %s, ad_synced_at = now() WHERE id = %s",
(Jsonb({"disabled": not gone, "gone": gone}), user_id),
)
conn.execute("DELETE FROM user_roles WHERE user_id = %s", (user_id,))
sessions.revoke_user(conn, user_id, reason)
def public_user(row: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(row["id"]),
"sam_account": row["sam_account"],
"upn": row.get("upn"),
"display_name": row["display_name"],
"emp_no": row.get("emp_no"),
"email": row.get("email"),
}
def ad_user_public(u: AdUser) -> dict[str, Any]:
return {
"id": str(u.guid),
"dn": u.dn,
"sam_account": u.sam_account,
"upn": u.upn,
"display_name": u.display_name,
"emp_no": u.emp_no,
"email": u.email,
}
# ---------------------------------------------------------------- AD refresh
@dataclass
class RefreshResult:
groups_checked: int = 0
groups_missing: list[str] = field(default_factory=list)
users_seen: int = 0
created: int = 0
updated: int = 0
deactivated: int = 0
emp_missing: list[str] = field(default_factory=list)
emp_duplicate: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {
"groups_checked": self.groups_checked,
"groups_missing": self.groups_missing,
"users_seen": self.users_seen,
"created": self.created,
"updated": self.updated,
"deactivated": self.deactivated,
"emp_missing": self.emp_missing,
"emp_duplicate": self.emp_duplicate,
}
def refresh_from_ad(
conn: Any, directory: DirectoryProtocol, *, actor: sessions.Actor | None = None, ip: str | None = None
) -> RefreshResult:
"""Enumerate every mapped group, upsert its members and rebuild their roles; deactivate gone accounts.
All AD reads happen first, then the database writes in one transaction, so no lock is held while AD
answers. Raises LdapUnavailable when AD cannot be read.
"""
res = RefreshResult()
names = groupmap.group_names()
in_ad = {g.lower() for g in directory.list_groups()}
res.groups_missing = [n for n in names if n.lower() not in in_ad]
present = [n for n in names if n.lower() in in_ad]
seen: dict[uuid.UUID, AdUser] = {}
member_groups: dict[uuid.UUID, set[str]] = {}
for name in present:
for u in directory.members_of(name):
seen.setdefault(u.guid, u)
member_groups.setdefault(u.guid, set()).add(name)
res.groups_checked = len(present)
res.users_seen = len(seen)
known = conn.execute("SELECT id FROM users WHERE active").fetchall()
unseen = [r[0] for r in known if r[0] not in seen]
# Accounts not in any group: still in AD (keep, no roles), disabled, or gone
unseen_state: dict[uuid.UUID, AdUser | None] = {uid: directory.get_user_by_guid(uid) for uid in unseen}
enabled = [u for u in seen.values() if not u.disabled]
res.emp_missing = sorted(u.sam_account for u in enabled if not u.emp_no)
counts = Counter(u.emp_no for u in enabled if u.emp_no)
res.emp_duplicate = sorted(e for e, n in counts.items() if n > 1)
dup = set(res.emp_duplicate)
with conn.transaction():
for guid, u in seen.items():
grp = groupmap.mapped_groups(member_groups[guid])
was_active_row = conn.execute("SELECT active FROM users WHERE id = %s", (guid,)).fetchone()
created = upsert_user(conn, u, groups=grp)
conn.execute(
"UPDATE users SET directory_flags = directory_flags || %s WHERE id = %s",
(Jsonb({"emp_duplicate": bool(u.emp_no and u.emp_no in dup), "gone": False}), guid),
)
if created:
res.created += 1
else:
res.updated += 1
if u.disabled:
set_roles(conn, guid, [])
sessions.revoke_user(conn, guid, "ad_disabled")
if was_active_row is not None and was_active_row[0]:
res.deactivated += 1
audit.append(
conn,
action="ad.user_deactivated",
user_id=actor.user_id if actor else None,
session_id=actor.session_id if actor else None,
role_snapshot=actor.role_snapshot if actor else None,
subject_type="user",
subject_id=guid,
detail={"reason": "disabled", "sam_account": u.sam_account},
ip=ip,
)
else:
set_roles(conn, guid, groupmap.roles_from_groups(grp))
for uid, found in unseen_state.items():
if found is None or found.disabled:
mark_inactive(conn, uid, reason="ad_disabled" if found else "ad_gone", gone=found is None)
res.deactivated += 1
audit.append(
conn,
action="ad.user_deactivated",
user_id=actor.user_id if actor else None,
session_id=actor.session_id if actor else None,
role_snapshot=actor.role_snapshot if actor else None,
subject_type="user",
subject_id=uid,
detail={"reason": "gone" if found is None else "disabled"},
ip=ip,
)
else:
upsert_user(conn, found, groups=[])
set_roles(conn, uid, [])
sessions.revoke_user(conn, uid, "roles_lost")
audit.append(
conn,
action="ad.refresh",
user_id=actor.user_id if actor else None,
session_id=actor.session_id if actor else None,
role_snapshot=actor.role_snapshot if actor else None,
detail={
"groups_checked": res.groups_checked,
"groups_missing": len(res.groups_missing),
"users_seen": res.users_seen,
"created": res.created,
"updated": res.updated,
"deactivated": res.deactivated,
"emp_missing": len(res.emp_missing),
"emp_duplicate": len(res.emp_duplicate),
},
ip=ip,
)
log.info("AD refresh: %s", {k: v for k, v in res.as_dict().items() if isinstance(v, int)})
return res