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()
+86
View File
@@ -0,0 +1,86 @@
"""Unit tests for cyclone.auth.sessions — Session create/validate/expire/touch."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import delete
from cyclone.auth import sessions, users
from cyclone.db import Session as DbSession
from cyclone.db import SessionLocal, User
@pytest.fixture(autouse=True)
def _clear():
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
yield
with SessionLocal()() as db:
db.execute(delete(DbSession))
db.execute(delete(User))
db.commit()
def _make_user():
with SessionLocal()() as db:
return users.create(db, username="sessuser", password="hunter2hunter2", role="user")
def test_create_session_returns_id_and_session():
user = _make_user()
with SessionLocal()() as db:
sid, sess = sessions.create(db, user_id=user.id)
assert len(sid) >= 32
assert sess.user_id == user.id
assert sess.expires_at > datetime.now(timezone.utc)
def test_get_valid_returns_session_for_active():
user = _make_user()
with SessionLocal()() as db:
sid, _ = sessions.create(db, user_id=user.id)
with SessionLocal()() as db:
got = sessions.get_valid(db, sid)
assert got is not None
assert got.user_id == user.id
def test_get_valid_returns_none_for_missing():
with SessionLocal()() as db:
assert sessions.get_valid(db, "does-not-exist") is None
def test_get_valid_returns_none_for_expired():
user = _make_user()
with SessionLocal()() as db:
sid, _ = sessions.create(db, user_id=user.id)
with SessionLocal()() as db:
sess = sessions.get_valid(db, sid)
sess.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
db.commit()
with SessionLocal()() as db:
assert sessions.get_valid(db, sid) is None
def test_delete_removes_session():
user = _make_user()
with SessionLocal()() as db:
sid, _ = sessions.create(db, user_id=user.id)
sessions.delete(db, sid)
with SessionLocal()() as db:
assert sessions.get_valid(db, sid) is None
def test_touch_extends_expiry():
user = _make_user()
with SessionLocal()() as db:
sid, sess = sessions.create(db, user_id=user.id)
original_expiry = sess.expires_at
sessions.touch(db, sid)
with SessionLocal()() as db:
refreshed = sessions.get_valid(db, sid)
assert refreshed.expires_at >= original_expiry