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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user