soulsync/core/security/login_provisioning.py
BoulderBadgeDad 5b52d579c5 Login mode: enforce "every profile has a password" at every write-point (no gaps)
Invariant: while security.require_login is on, every profile must have a login
password or it's locked out. Previously only the admin's own anti-lockout existed,
so members could be stranded (created without a password, or login flipped on while
passwordless members existed). Closed all the write-points:

core/security/login_provisioning.py (pure policy, single source of truth):
- members_without_password(profiles) — non-admin profiles that can't sign in
- create_needs_password(require_login) / removing_password_strands(require_login)

Wired into web_server:
- create_profile: while login is on, a new member must be given a password (400
  otherwise) and it's set on creation.
- enable-login (settings save): refuses to turn login on while any member lacks a
  password — lists them — same shape as the existing admin anti-lockout.
- set-password: refuses to CLEAR a password while login is on (would strand them).

UI: Create Profile form gains a login-password field (alongside the optional PIN);
the Manage Profiles per-member password button (prior commit) covers existing
members + changes.

Tests: pure policy seam + endpoint enforcement (create blocked w/o password when
on, allowed w/ password, no friction when off, clear blocked when on). 442
profile/settings/auth tests green; ruff clean.
2026-06-11 19:48:50 -07:00

45 lines
1.8 KiB
Python

"""Login-mode password provisioning policy.
Invariant: while ``security.require_login`` is on, every profile must have a login
password — otherwise it's fail-closed locked out (usable only after the admin
provisions one). That's not a security hole (no-password = can't get in), but it's
a usability gap, and the point here is to make it impossible to OPEN one from any
write-point: creating a profile, clearing a password, or flipping login mode on.
These are pure decisions so they're the single source of truth + unit-testable;
web_server wires them into the create / set-password / enable-login endpoints, and
the UI mirrors them.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def members_without_password(profiles: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
"""Non-admin profiles with no login password — they can't sign in once login
mode is on. The admin is covered separately by its own anti-lockout, so it's
excluded here. Returns ``[{'id', 'name'}, …]`` (empty = no gap)."""
out: List[Dict[str, Any]] = []
for p in (profiles or []):
if not p.get('is_admin') and not p.get('has_password'):
out.append({'id': p.get('id'), 'name': p.get('name')})
return out
def create_needs_password(require_login: bool, is_admin: bool = False) -> bool:
"""A non-admin profile created while login mode is on must carry a password,
or it's born unable to sign in."""
return bool(require_login) and not is_admin
def removing_password_strands(require_login: bool) -> bool:
"""Clearing a profile's password while login mode is on would lock it out."""
return bool(require_login)
__all__ = [
"members_without_password",
"create_needs_password",
"removing_password_strands",
]