feat(backend): add local auth + group-based RBAC matching F11 (B0.6)

- Permission enum + GroupName enum + GROUP_PERMISSIONS mapping mirror
  the F11 matrix in code (verifiable against the spec table in tests).
- @require_perm decorator: 401 on anonymous, 403 on missing permission,
  passes through otherwise. Pure-function user_has() for unit-testing.
- AuthUser (Flask-Login wrapper) resolves the permission set from a
  User's groups; load_user is the Flask-Login user_loader.
- bcrypt password hashing helpers (12 rounds by default, configurable).
- SOC opaque token (D-006): secrets.token_urlsafe(32), bcrypt-hashed at
  rest, plain value returned once at creation and never re-displayable.
- Group-based RBAC from day one (D-003) — Keycloak OIDC in v2 maps onto
  the same group model.
This commit is contained in:
knacky
2026-05-21 20:33:31 +02:00
parent 104d73143a
commit 7f4ad85a68
7 changed files with 289 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
"""SOC opaque token generation + verification.
Decision D-006: clear token returned once at creation, bcrypt hash persisted.
"""
from __future__ import annotations
import hmac
import secrets
from dataclasses import dataclass
import bcrypt
_TOKEN_BYTES = 32 # 256 bits of entropy
_DEFAULT_ROUNDS = 12
@dataclass(frozen=True, slots=True)
class SocTokenMaterial:
plain: str
hashed: str
def generate_token(*, rounds: int = _DEFAULT_ROUNDS) -> SocTokenMaterial:
"""Generate a fresh SOC token and its bcrypt hash."""
plain = secrets.token_urlsafe(_TOKEN_BYTES)
salt = bcrypt.gensalt(rounds=rounds)
hashed = bcrypt.hashpw(plain.encode("utf-8"), salt).decode("utf-8")
return SocTokenMaterial(plain=plain, hashed=hashed)
def verify_token(plain: str, hashed: str) -> bool:
"""Constant-time bcrypt verification of a SOC token."""
if not plain or not hashed:
return False
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except (ValueError, TypeError):
return False
def safe_compare(a: str, b: str) -> bool:
"""Constant-time string compare for non-hashed contexts."""
return hmac.compare_digest(a, b)