diff --git a/backend/src/cyclone/auth/permissions.py b/backend/src/cyclone/auth/permissions.py new file mode 100644 index 0000000..c0f01c8 --- /dev/null +++ b/backend/src/cyclone/auth/permissions.py @@ -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] \ No newline at end of file diff --git a/backend/src/cyclone/auth/rate_limit.py b/backend/src/cyclone/auth/rate_limit.py new file mode 100644 index 0000000..090258c --- /dev/null +++ b/backend/src/cyclone/auth/rate_limit.py @@ -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) \ No newline at end of file diff --git a/backend/src/cyclone/auth/sessions.py b/backend/src/cyclone/auth/sessions.py new file mode 100644 index 0000000..4dcb9bc --- /dev/null +++ b/backend/src/cyclone/auth/sessions.py @@ -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() \ No newline at end of file diff --git a/backend/tests/test_auth_sessions.py b/backend/tests/test_auth_sessions.py new file mode 100644 index 0000000..7ef34c7 --- /dev/null +++ b/backend/tests/test_auth_sessions.py @@ -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 \ No newline at end of file