Commit 81405750 authored by George Papathanail's avatar George Papathanail
Browse files

fix: fallback to sub as tenant_id when the token has no organization claim

parent 0a3ee432
Loading
Loading
Loading
Loading
Loading
+8 −4
Changes for src/open_exposure_gateway/dependencies.py: 8 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -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(),
    )

+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"