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