Loading src/open_exposure_gateway/dependencies.py +8 −4 Changes for src/open_exposure_gateway/dependencies.py: 8 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -143,21 +143,25 @@ async def get_caller_context( return _unverified_caller_context(x_correlator_header(request)) claims = decoded.claims tenant_id = claims.get("organization") subject = claims.get("sub") roles = claims.get("realm_access", {}).get("roles") if not tenant_id or not roles: if not subject or not roles: if enforcement_enabled: raise UnauthorizedException(message="Access token missing required claims") logger.warning("auth_would_reject", reason="missing_claims", path=request.url.path) return _unverified_caller_context(x_correlator_header(request)) # Only a platform admin can set `organization`; until one does, the user is a # tenant of their own (keycloak chart README, "Organizations and tenants"). tenant_id: str = claims.get("organization") or subject return CallerContext( x_correlator=x_correlator_header(request), tenant_id=tenant_id, app_provider_id=claims["sub"], app_provider_id=subject, roles=roles, flow=decoded.flow, subject_id=claims["sub"] if decoded.flow == TokenFlow.THREE_LEGGED else None, subject_id=subject if decoded.flow == TokenFlow.THREE_LEGGED else None, scopes=claims.get("scope", "").split(), ) Loading tests/unit/test_caller_context.py 0 → 100644 +86 −0 Changes for tests/unit/test_caller_context.py: 86 added lines, 0 removed lines. Original line number Diff line number Diff line """Unit tests for get_caller_context's claim handling: which token claims become the caller's tenant_id/app_provider_id, and which missing claims reject the request. JWT validation itself is covered by test_core_auth.py; here the verifier is a stub returning fixed claims.""" from typing import Any import pytest from fastapi import Request from fastapi.security import HTTPAuthorizationCredentials from open_exposure_gateway.core.auth.tokens import DecodedClaims from open_exposure_gateway.core.config import KeycloakSettings, Settings from open_exposure_gateway.core.exceptions import UnauthorizedException from open_exposure_gateway.dependencies import CallerContext, get_caller_context class _StubVerifier: def __init__(self, claims: dict[str, Any]) -> None: self._claims = claims async def verify(self, token: str) -> DecodedClaims: return DecodedClaims(claims=self._claims) def _request() -> Request: return Request( scope={ "type": "http", "method": "GET", "path": "/", "root_path": "", "query_string": b"", "headers": [], "scheme": "http", "server": ("test", 80), } ) def _claims(**overrides: Any) -> dict[str, Any]: claims: dict[str, Any] = { "sub": "user-123", "realm_access": {"roles": ["app-provider"]}, "organization": "acme-labs", **overrides, } return {k: v for k, v in claims.items() if v is not None} async def _caller(claims: dict[str, Any], enforcement_enabled: bool = True) -> CallerContext: return await get_caller_context( request=_request(), x_correlator=None, credentials=HTTPAuthorizationCredentials(scheme="Bearer", credentials="token"), token_verifier=_StubVerifier(claims), # type: ignore[arg-type] settings=Settings( keycloak_settings=KeycloakSettings(enforcement_enabled=enforcement_enabled) ), ) class TestTenantId: async def test_organization_is_the_tenant(self) -> None: caller = await _caller(_claims()) assert caller.tenant_id == "acme-labs" assert caller.app_provider_id == "user-123" async def test_user_without_organization_is_their_own_tenant(self) -> None: caller = await _caller(_claims(organization=None)) assert caller.tenant_id == "user-123" assert caller.app_provider_id == "user-123" class TestMissingClaims: async def test_missing_sub_is_rejected(self) -> None: with pytest.raises(UnauthorizedException): await _caller(_claims(sub=None)) async def test_missing_roles_is_rejected(self) -> None: with pytest.raises(UnauthorizedException): await _caller(_claims(realm_access=None)) async def test_missing_sub_is_unverified_when_enforcement_disabled(self) -> None: caller = await _caller(_claims(sub=None), enforcement_enabled=False) assert caller.tenant_id == "unverified" Loading
src/open_exposure_gateway/dependencies.py +8 −4 Changes for src/open_exposure_gateway/dependencies.py: 8 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -143,21 +143,25 @@ async def get_caller_context( return _unverified_caller_context(x_correlator_header(request)) claims = decoded.claims tenant_id = claims.get("organization") subject = claims.get("sub") roles = claims.get("realm_access", {}).get("roles") if not tenant_id or not roles: if not subject or not roles: if enforcement_enabled: raise UnauthorizedException(message="Access token missing required claims") logger.warning("auth_would_reject", reason="missing_claims", path=request.url.path) return _unverified_caller_context(x_correlator_header(request)) # Only a platform admin can set `organization`; until one does, the user is a # tenant of their own (keycloak chart README, "Organizations and tenants"). tenant_id: str = claims.get("organization") or subject return CallerContext( x_correlator=x_correlator_header(request), tenant_id=tenant_id, app_provider_id=claims["sub"], app_provider_id=subject, roles=roles, flow=decoded.flow, subject_id=claims["sub"] if decoded.flow == TokenFlow.THREE_LEGGED else None, subject_id=subject if decoded.flow == TokenFlow.THREE_LEGGED else None, scopes=claims.get("scope", "").split(), ) Loading
tests/unit/test_caller_context.py 0 → 100644 +86 −0 Changes for tests/unit/test_caller_context.py: 86 added lines, 0 removed lines. Original line number Diff line number Diff line """Unit tests for get_caller_context's claim handling: which token claims become the caller's tenant_id/app_provider_id, and which missing claims reject the request. JWT validation itself is covered by test_core_auth.py; here the verifier is a stub returning fixed claims.""" from typing import Any import pytest from fastapi import Request from fastapi.security import HTTPAuthorizationCredentials from open_exposure_gateway.core.auth.tokens import DecodedClaims from open_exposure_gateway.core.config import KeycloakSettings, Settings from open_exposure_gateway.core.exceptions import UnauthorizedException from open_exposure_gateway.dependencies import CallerContext, get_caller_context class _StubVerifier: def __init__(self, claims: dict[str, Any]) -> None: self._claims = claims async def verify(self, token: str) -> DecodedClaims: return DecodedClaims(claims=self._claims) def _request() -> Request: return Request( scope={ "type": "http", "method": "GET", "path": "/", "root_path": "", "query_string": b"", "headers": [], "scheme": "http", "server": ("test", 80), } ) def _claims(**overrides: Any) -> dict[str, Any]: claims: dict[str, Any] = { "sub": "user-123", "realm_access": {"roles": ["app-provider"]}, "organization": "acme-labs", **overrides, } return {k: v for k, v in claims.items() if v is not None} async def _caller(claims: dict[str, Any], enforcement_enabled: bool = True) -> CallerContext: return await get_caller_context( request=_request(), x_correlator=None, credentials=HTTPAuthorizationCredentials(scheme="Bearer", credentials="token"), token_verifier=_StubVerifier(claims), # type: ignore[arg-type] settings=Settings( keycloak_settings=KeycloakSettings(enforcement_enabled=enforcement_enabled) ), ) class TestTenantId: async def test_organization_is_the_tenant(self) -> None: caller = await _caller(_claims()) assert caller.tenant_id == "acme-labs" assert caller.app_provider_id == "user-123" async def test_user_without_organization_is_their_own_tenant(self) -> None: caller = await _caller(_claims(organization=None)) assert caller.tenant_id == "user-123" assert caller.app_provider_id == "user-123" class TestMissingClaims: async def test_missing_sub_is_rejected(self) -> None: with pytest.raises(UnauthorizedException): await _caller(_claims(sub=None)) async def test_missing_roles_is_rejected(self) -> None: with pytest.raises(UnauthorizedException): await _caller(_claims(realm_access=None)) async def test_missing_sub_is_unverified_when_enforcement_disabled(self) -> None: caller = await _caller(_claims(sub=None), enforcement_enabled=False) assert caller.tenant_id == "unverified"