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.
45 lines
1.8 KiB
Python
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",
|
|
]
|