feat(auth): sessions module + permissions matrix + rate limiter

SQLite drops tzinfo on DateTime roundtrip — normalize on read/write
so callers see tz-aware datetimes.
This commit is contained in:
Nora
2026-06-22 14:33:20 -06:00
parent 1ca50e2bc0
commit e158871a9a
4 changed files with 253 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
"""Role enum + PERMISSIONS matrix."""
from __future__ import annotations
from enum import Enum
class Role(str, Enum):
ADMIN = "admin"
USER = "user"
VIEWER = "viewer"
ALL_ROLES = {Role.ADMIN, Role.USER, Role.VIEWER}
WRITE_ROLES = {Role.ADMIN, Role.USER}
ADMIN_ONLY = {Role.ADMIN}
# (method, path-prefix) → allowed roles.
# Endpoints not in this matrix default to DENY (fail-closed).
PERMISSIONS: dict[tuple[str, str], set[Role]] = {
# Public paths.
("GET", "/api/healthz"): set(),
("POST", "/api/auth/login"): set(),
# Auth surface.
("POST", "/api/auth/logout"): ALL_ROLES,
("GET", "/api/auth/me"): ALL_ROLES,
# Admin-only user management.
("GET", "/api/admin/users"): ADMIN_ONLY,
("POST", "/api/admin/users"): ADMIN_ONLY,
("PATCH", "/api/admin/users"): ADMIN_ONLY,
("DELETE", "/api/admin/users"): ADMIN_ONLY,
# Read endpoints (all authenticated roles).
("GET", "/api/claims"): ALL_ROLES,
("GET", "/api/remittances"): ALL_ROLES,
("GET", "/api/providers"): ALL_ROLES,
("GET", "/api/batches"): ALL_ROLES,
("GET", "/api/dashboard/summary"): ALL_ROLES,
("GET", "/api/activity"): ALL_ROLES,
("GET", "/api/inbox/lanes"): ALL_ROLES,
("GET", "/api/reconcile"): ALL_ROLES,
("GET", "/api/audit-log"): ADMIN_ONLY,
# Write endpoints (admin + user, no viewer).
("POST", "/api/parse-837"): WRITE_ROLES,
("POST", "/api/parse-835"): WRITE_ROLES,
("POST", "/api/inbox"): WRITE_ROLES,
("POST", "/api/reconcile"): WRITE_ROLES,
("POST", "/api/resubmit"): WRITE_ROLES,
("POST", "/api/acks"): WRITE_ROLES,
# CSV export — read-only.
("GET", "/api/export.csv"): ALL_ROLES,
}
def allowed_roles(method: str, path: str) -> set[Role] | None:
"""Return the set of roles allowed to call (method, path), or None if denied.
Uses longest-prefix match on path; falls back to DENY (None) if no entry matches.
"""
candidates = [
(len(prefix), roles)
for (m, prefix), roles in PERMISSIONS.items()
if m == method and (path == prefix or path.startswith(prefix.rstrip("/") + "/"))
]
if not candidates:
return None
candidates.sort(key=lambda x: -x[0])
return candidates[0][1]
+34
View File
@@ -0,0 +1,34 @@
"""Per-username login rate limiter (in-memory, per-process)."""
from __future__ import annotations
import time
from threading import Lock
WINDOW_SECONDS = 300
MAX_FAILS = 5
_FAILS: dict[str, list[float]] = {}
_LOCK = Lock()
def check(username: str) -> int:
"""Return retry-after seconds, or 0 if allowed."""
now = time.monotonic()
with _LOCK:
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
_FAILS[username] = fails
if len(fails) >= MAX_FAILS:
return int(WINDOW_SECONDS - (now - fails[0]))
return 0
def record_failure(username: str) -> None:
now = time.monotonic()
with _LOCK:
_FAILS.setdefault(username, []).append(now)
def reset(username: str) -> None:
with _LOCK:
_FAILS.pop(username, None)
+60
View File
@@ -0,0 +1,60 @@
"""Session create/validate/expire/touch."""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from cyclone.db import Session
SESSION_LIFETIME = timedelta(hours=24)
def create(db, *, user_id: int) -> tuple[str, Session]:
sid = secrets.token_urlsafe(32)
now = datetime.now(timezone.utc)
expires_at = now + SESSION_LIFETIME
sess = Session(
id=sid,
user_id=user_id,
expires_at=expires_at,
created_at=now,
)
db.add(sess)
db.commit()
db.refresh(sess)
# SQLite strips tzinfo on roundtrip; restore it so callers don't have to.
sess.expires_at = expires_at
return sid, sess
def get_valid(db, sid: str) -> Session | None:
sess = db.execute(
select(Session).where(Session.id == sid)
).scalar_one_or_none()
if sess is None:
return None
# SQLite drops tzinfo on roundtrip; normalize to UTC before comparing.
if sess.expires_at.tzinfo is None:
sess.expires_at = sess.expires_at.replace(tzinfo=timezone.utc)
if sess.expires_at <= datetime.now(timezone.utc):
return None
return sess
def delete(db, sid: str) -> None:
sess = db.get(Session, sid)
if sess is None:
return
db.delete(sess)
db.commit()
def touch(db, sid: str) -> None:
sess = db.get(Session, sid)
if sess is None:
return
sess.expires_at = datetime.now(timezone.utc) + SESSION_LIFETIME
db.commit()