feat(auth): OperatorUser model + role ladder

Add OperatorUser SQLAlchemy model (operator_users table, auto-created by
create_all) with email uniqueness, default active/must_change_password/
failed_login_count, and sessions_valid_from truncated to whole seconds.
Add backend/operator_auth.py with feature flag, cookie constants, _ROLE_RANK
map, role_at_least(), and _norm_email() helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 19:08:18 +00:00
parent 8e817ec48d
commit 4abfcbc293
3 changed files with 102 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# backend/operator_auth.py
"""Operator authentication: the deny-by-default gate, session cookie, login +
lockout, and the small data helpers shared by the routes and the CLI. Reuses the
argon2 hasher (auth_passwords) and the HMAC signer (auth_cookies).
The flag and SessionLocal are read as module globals at call time so tests can
monkeypatch them."""
import os
import time
import uuid
from datetime import datetime, timedelta
from backend.models import OperatorUser
from backend.auth_passwords import hash_password, verify_password, generate_password
# Feature flag — OFF by default. When off, the gate and require_role both pass
# everything through and the app behaves exactly as it does today.
OPERATOR_AUTH_ENABLED = os.getenv("OPERATOR_AUTH_ENABLED", "false").lower() in ("1", "true", "yes")
COOKIE_NAME = "tv_session"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days ("remember this device")
MAX_LOGIN_FAILURES = 5
LOCK_MINUTES = 15
# Role ladder — a rank map so checks read naturally and 'operator' slots in later.
_ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30}
def role_at_least(role: str, minimum: str) -> bool:
"""True iff `role` ranks at or above `minimum`. Unknown roles rank as 0."""
return _ROLE_RANK.get(role, 0) >= _ROLE_RANK[minimum]
def _norm_email(email: str) -> str:
return (email or "").strip().lower()