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
+31
View File
@@ -3,6 +3,13 @@ from datetime import datetime
from backend.database import Base
def _utcnow_seconds():
"""utcnow truncated to whole seconds — used as the default for
sessions_valid_from so a freshly-issued cookie (whose iat is a whole-second
epoch) never falls a few microseconds before it and self-invalidates."""
return datetime.utcnow().replace(microsecond=0)
class Emitter(Base):
__tablename__ = "emitters"
@@ -772,3 +779,27 @@ class ClientAccessToken(Base):
created_at = Column(DateTime, default=datetime.utcnow)
last_used_at = Column(DateTime, nullable=True)
revoked_at = Column(DateTime, nullable=True) # set = link no longer works
# ============================================================================
# OPERATOR AUTH — internal operator logins (see backend/operator_auth.py)
# ============================================================================
class OperatorUser(Base):
"""An internal operator login. Roles: 'superadmin' (Brian, + account mgmt) and
'admin' (parents, full app). 'operator' is reserved (deferred). Brand-new table
→ create_all builds it, no migration. Never store or log raw passwords."""
__tablename__ = "operator_users"
id = Column(String, primary_key=True, index=True) # UUID
email = Column(String, nullable=False, unique=True, index=True) # login handle, lowercased
display_name = Column(String, nullable=False) # "Brian", "Dad"
password_hash = Column(String, nullable=False) # argon2id
role = Column(String, nullable=False, default="admin") # superadmin | admin
active = Column(Boolean, default=True) # False = login disabled
must_change_password = Column(Boolean, default=False) # forces a change next login
sessions_valid_from = Column(DateTime, default=_utcnow_seconds) # bump = log out everywhere
failed_login_count = Column(Integer, default=0) # lockout counter
locked_until = Column(DateTime, nullable=True) # set after too many bad tries
created_at = Column(DateTime, default=datetime.utcnow)
last_login_at = Column(DateTime, nullable=True)