from __future__ import annotations import unittest from datetime import timedelta from unittest.mock import patch from fastapi import HTTPException from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from starlette.requests import Request from govoplan_access.backend.auth.dependencies import _resolve_legacy_principal_context from govoplan_access.backend.auth.principal_cache import principal_summary_cache from govoplan_access.backend.auth.tokens import hash_secret from govoplan_access.backend.db.base import AccessBase from govoplan_access.backend.db.models import Account, AuthSession, Role, ServiceAccount, User, UserRoleAssignment from govoplan_access.backend.security.api_keys import create_api_key from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor from govoplan_core.db.base import Base from govoplan_core.security.time import utc_now from govoplan_core.settings import settings from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry class AuthCacheSecurityTests(unittest.TestCase): def setUp(self) -> None: principal_summary_cache.clear() self.engine = create_engine("sqlite:///:memory:") create_scope_tables(self.engine) AccessBase.metadata.create_all(self.engine) self.revision_tables = [ChangeSequenceEntry.__table__, ChangeSequenceRetentionFloor.__table__] Base.metadata.create_all(self.engine, tables=self.revision_tables) self.session = sessionmaker(bind=self.engine)() self.cache_setting = patch.object(settings, "auth_principal_cache_enabled", True) self.cache_setting.start() self.tenant = Tenant(id="cache-tenant", slug="cache-tenant", name="Cache tenant") self.session.add(self.tenant) self.session.commit() def tearDown(self) -> None: self.cache_setting.stop() principal_summary_cache.clear() self.session.close() AccessBase.metadata.drop_all(self.engine) scope_registry.metadata.drop_all(self.engine) Base.metadata.drop_all(self.engine, tables=self.revision_tables) self.engine.dispose() def identity(self, *, service: bool = False) -> tuple[Account, User]: account = Account(id="cache-account", email="cache@example.test", normalized_email="cache@example.test", auth_provider="service_account" if service else "local") user = User(id="cache-user", tenant_id=self.tenant.id, account_id=account.id, email=account.email, auth_provider=account.auth_provider) role = Role(id="cache-role", tenant_id=self.tenant.id, slug="reader", name="Reader", permissions=["files:file:read"]) assignment = UserRoleAssignment(tenant_id=self.tenant.id, user_id=user.id, role_id=role.id) self.session.add_all([account, user, role, assignment]) self.session.commit() return account, user def resolve(self, token: str, *, cookie: bool = False, csrf: str | None = None): headers = [] if cookie: cookies = f"{settings.auth_session_cookie_name}={token}" if csrf is not None: cookies += f"; {settings.auth_csrf_cookie_name}={csrf}" headers.append((b"x-csrf-token", csrf.encode())) headers.append((b"cookie", cookies.encode())) request = Request({"type": "http", "method": "POST", "path": "/protected", "headers": headers}) return _resolve_legacy_principal_context(request, self.session, authorization=None if cookie else f"Bearer {token}", x_api_key=None) def test_warmed_api_key_is_never_accepted_as_a_session_cookie(self) -> None: _, user = self.identity() key = create_api_key(self.session, user=user, name="Test", scopes=["files:file:read"]) self.session.commit() with self.assertRaises(HTTPException) as cold: self.resolve(key.secret, cookie=True) self.assertEqual(401, cold.exception.status_code) self.assertEqual("api_key", self.resolve(key.secret).principal.auth_method) with self.assertRaises(HTTPException) as warm: self.resolve(key.secret, cookie=True) self.assertEqual(401, warm.exception.status_code) self.assertEqual("api_key", self.resolve(key.secret).principal.auth_method) def test_service_account_keeps_current_ceiling_and_provenance_with_cache_enabled(self) -> None: account, user = self.identity(service=True) item = ServiceAccount(id="cache-service", tenant_id=self.tenant.id, account_id=account.id, membership_id=user.id, name="Cache worker", normalized_name="cache worker", scope_ceiling=["dataflow:pipeline:run"]) # A credential issued before a ceiling reduction can retain wider stored # scopes. Ordinary membership roles must not override the current ceiling. key = create_api_key(self.session, user=user, name="Worker", scopes=["dataflow:pipeline:run", "files:file:read"]) self.session.add(item) self.session.commit() for _ in range(2): context = self.resolve(key.secret) self.assertEqual(frozenset({"dataflow:pipeline:run"}), context.principal.scopes) self.assertEqual("service_account", context.principal.auth_method) self.assertEqual(item.id, context.principal.service_account_id) self.assertFalse(context.principal.role_ids) item.scope_ceiling = [] self.session.commit() self.assertEqual(frozenset(), self.resolve(key.secret).principal.scopes) item.is_active = False self.session.commit() with self.assertRaises(HTTPException) as inactive: self.resolve(key.secret) self.assertEqual(401, inactive.exception.status_code) def test_warmed_session_cookie_still_requires_matching_csrf(self) -> None: account, user = self.identity() token, csrf = "ms_cache-session", "cache-csrf" auth_session = AuthSession(id="cache-session", tenant_id=self.tenant.id, user_id=user.id, account_id=account.id, token_hash=hash_secret(token), csrf_token_hash=hash_secret(csrf), expires_at=utc_now() + timedelta(hours=1)) self.session.add(auth_session) self.session.commit() self.resolve(token) for supplied in (None, "incorrect"): with self.subTest(csrf=supplied), self.assertRaises(HTTPException) as denied: self.resolve(token, cookie=True, csrf=supplied) self.assertEqual(403, denied.exception.status_code) self.assertEqual("session", self.resolve(token, cookie=True, csrf=csrf).principal.auth_method) if __name__ == "__main__": unittest.main()