Release govoplan-access v0.1.25: harden authentication and repair identity mappings
Module Package Release / publish-packages (push) Successful in 15s

This commit is contained in:
2026-09-08 01:32:20 +02:00
parent 0f8a05f8b9
commit 8f8072b4ae
25 changed files with 742 additions and 38 deletions
+120
View File
@@ -0,0 +1,120 @@
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()