Commit 550ea468 authored by George Papathanail's avatar George Papathanail
Browse files

feat: enforce CAMARA-declared OAuth2 scopes per operation

parent 63d28807
Loading
Loading
Loading
Loading
+11 −5
Changes for src/open_exposure_gateway/api/camara/edge_application_management/v0_1_0_alpha_1/router.py: 11 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -20,7 +20,11 @@ from open_exposure_gateway.api.camara.edge_application_management.v0_1_0_alpha_1
from open_exposure_gateway.application.services.edge_application_management_service import (
    EdgeApplicationManagementService,
)
from open_exposure_gateway.core.authorization import require_role, require_three_legged
from open_exposure_gateway.core.authorization import (
    require_role,
    require_scope,
    require_three_legged,
)
from open_exposure_gateway.core.exceptions import (
    AbortedException,
    AlreadyExistsException,
@@ -44,11 +48,13 @@ from open_exposure_gateway.schemas.common import ErrorInfo
# by this package, {apiRoot} by the deployment.
BASE_PATH = "/edge-application-management/v0.1alpha1"

# require_role/require_three_legged apply to every route in this router, looking each
# request's requirements up in the one explicit table (core/authorization.py) -- so
# authorization is enforced identically here without touching individual handlers.
# require_role/require_three_legged/require_scope apply to every route in this router,
# looking each request's requirements up in the one explicit table
# (core/authorization.py) -- so authorization is enforced identically here without
# touching individual handlers.
router = APIRouter(
    prefix=BASE_PATH, dependencies=[Depends(require_role), Depends(require_three_legged)]
    prefix=BASE_PATH,
    dependencies=[Depends(require_role), Depends(require_three_legged), Depends(require_scope)],
)

EdgeAppService = Annotated[EdgeApplicationManagementService, Depends(get_edge_app_service)]
+11 −5
Changes for src/open_exposure_gateway/api/camara/location_retrieval/v0_5_0/router.py: 11 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -10,7 +10,11 @@ from open_exposure_gateway.api.camara.location_retrieval.v0_5_0.schemas import (
from open_exposure_gateway.application.services.location_retrieval_service import (
    LocationRetrievalService,
)
from open_exposure_gateway.core.authorization import require_role, require_three_legged
from open_exposure_gateway.core.authorization import (
    require_role,
    require_scope,
    require_three_legged,
)
from open_exposure_gateway.core.exceptions import (
    BadRequestException,
    DownstreamServiceException,
@@ -31,11 +35,13 @@ from open_exposure_gateway.schemas.common import ErrorInfo
# v0.5.0), mounted bare per ADR-0020.
BASE_PATH = "/location-retrieval/v0.5"

# require_role/require_three_legged apply to every route in this router, looking each
# request's requirements up in the one explicit table (core/authorization.py) -- so
# authorization is enforced identically here without touching individual handlers.
# require_role/require_three_legged/require_scope apply to every route in this router,
# looking each request's requirements up in the one explicit table
# (core/authorization.py) -- so authorization is enforced identically here without
# touching individual handlers.
router = APIRouter(
    prefix=BASE_PATH, dependencies=[Depends(require_role), Depends(require_three_legged)]
    prefix=BASE_PATH,
    dependencies=[Depends(require_role), Depends(require_three_legged), Depends(require_scope)],
)

LocationService = Annotated[LocationRetrievalService, Depends(get_location_retrieval_service)]
+11 −5
Changes for src/open_exposure_gateway/api/camara/quality_on_demand/v0_10_1/router.py: 11 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -11,7 +11,11 @@ from open_exposure_gateway.api.camara.quality_on_demand.v0_10_1.schemas import (
from open_exposure_gateway.application.services.quality_on_demand_service import (
    QualityOnDemandService,
)
from open_exposure_gateway.core.authorization import require_role, require_three_legged
from open_exposure_gateway.core.authorization import (
    require_role,
    require_scope,
    require_three_legged,
)
from open_exposure_gateway.core.exceptions import (
    BadRequestException,
    ConflictException,
@@ -27,11 +31,13 @@ from open_exposure_gateway.schemas.common import ErrorInfo
# CAMARA base path: the spec serves at {apiRoot}/qod/v0 (wire version of v0.10.1).
BASE_PATH = "/qod/v0"

# require_role/require_three_legged apply to every route in this router, looking each
# request's requirements up in the one explicit table (core/authorization.py) -- so
# authorization is enforced identically here without touching individual handlers.
# require_role/require_three_legged/require_scope apply to every route in this router,
# looking each request's requirements up in the one explicit table
# (core/authorization.py) -- so authorization is enforced identically here without
# touching individual handlers.
router = APIRouter(
    prefix=BASE_PATH, dependencies=[Depends(require_role), Depends(require_three_legged)]
    prefix=BASE_PATH,
    dependencies=[Depends(require_role), Depends(require_three_legged), Depends(require_scope)],
)

QoDService = Annotated[QualityOnDemandService, Depends(get_qod_service)]
+77 −20
Changes for src/open_exposure_gateway/core/authorization.py: 77 added lines, 20 removed lines.
Original line number Diff line number Diff line
from dataclasses import dataclass
from typing import Annotated
from typing import Annotated, Optional

import structlog
from fastapi import Depends, Request
@@ -16,6 +16,16 @@ logger = structlog.get_logger(__name__)
class OperationRequirements:
    role: str
    requires_three_legged: bool
    # The CAMARA scope this operation's spec declares (e.g. "qod-sessions-write"), or
    # None where the spec declares no scope at all (e.g. QoD's qos-profiles reads).
    scope: Optional[str] = None
    # QoD's spec offers two alternative security requirements per operation --
    # `oAuth2ClientCredentials` (two-legged, no scope) OR `threeLegged` (a specific
    # scope required). EAM/Location Retrieval instead use `openIdConnect`, a single
    # scheme where the scope is required regardless of flow. False here reproduces
    # QoD's "two-legged callers are exempt" alternative; defaults to True (always
    # required) to match EAM/Location Retrieval.
    scope_required_for_two_legged: bool = True


# One explicit table per CAMARA operation, not per-handler if statements: this is what keeps
@@ -27,57 +37,79 @@ class OperationRequirements:
OPERATION_REQUIREMENTS: dict[str, OperationRequirements] = {
    # Quality on Demand -- app-provider only; the spec allows a three-legged token on
    # get/delete/extend but never requires one, and createSession is two-legged only.
    "POST /qod/v0/sessions": OperationRequirements("app-provider", False),
    "GET /qod/v0/sessions/{sessionId}": OperationRequirements("app-provider", False),
    "DELETE /qod/v0/sessions/{sessionId}": OperationRequirements("app-provider", False),
    "POST /qod/v0/sessions/{sessionId}/extend": OperationRequirements("app-provider", False),
    # Scopes match qod-api.yaml's `security` block per operation: a two-legged
    # (oAuth2ClientCredentials) token needs no scope, a three-legged token must carry
    # the listed one. qos-profiles reads declare no scope at all in the spec.
    "POST /qod/v0/sessions": OperationRequirements(
        "app-provider", False, scope="qod-sessions-write", scope_required_for_two_legged=False
    ),
    "GET /qod/v0/sessions/{sessionId}": OperationRequirements(
        "app-provider", False, scope="qod-sessions-read", scope_required_for_two_legged=False
    ),
    "DELETE /qod/v0/sessions/{sessionId}": OperationRequirements(
        "app-provider", False, scope="qod-sessions-delete", scope_required_for_two_legged=False
    ),
    "POST /qod/v0/sessions/{sessionId}/extend": OperationRequirements(
        "app-provider", False, scope="qod-sessions-write", scope_required_for_two_legged=False
    ),
    "GET /qod/v0/qos-profiles": OperationRequirements("app-provider", False),
    "GET /qod/v0/qos-profiles/{name}": OperationRequirements("app-provider", False),
    # Edge Application Management -- app-provider manages its own apps/instances/deployments;
    # no end-user consent is ever involved.
    # no end-user consent is ever involved. Scopes match
    # edge-application-management-0.1.0-alpha.1.yaml's `openId` security block per
    # operation, which (unlike QoD) requires the scope regardless of flow.
    "GET /edge-application-management/v0.1alpha1/edge-cloud-zones": OperationRequirements(
        "app-provider", False
        "app-provider", False, scope="edge-application-management:edge-cloud-zones:read"
    ),
    "GET /edge-application-management/v0.1alpha1/apps": OperationRequirements(
        "app-provider", False
        "app-provider", False, scope="edge-application-management:apps:read"
    ),
    "GET /edge-application-management/v0.1alpha1/apps/{appId}": OperationRequirements(
        "app-provider", False
        "app-provider", False, scope="edge-application-management:apps:read"
    ),
    "POST /edge-application-management/v0.1alpha1/apps": OperationRequirements(
        "app-provider", False
        "app-provider", False, scope="edge-application-management:apps:write"
    ),
    "DELETE /edge-application-management/v0.1alpha1/apps/{appId}": OperationRequirements(
        "app-provider", False
        "app-provider", False, scope="edge-application-management:apps:delete"
    ),
    "POST /edge-application-management/v0.1alpha1/appinstances": OperationRequirements(
        "app-provider", False
        "app-provider", False, scope="edge-application-management:instances:write"
    ),
    "GET /edge-application-management/v0.1alpha1/appinstances": OperationRequirements(
        "app-provider", False
        "app-provider", False, scope="edge-application-management:instances:read"
    ),
    "DELETE /edge-application-management/v0.1alpha1/appinstances/{appInstanceId}": (
        OperationRequirements("app-provider", False)
        OperationRequirements(
            "app-provider", False, scope="edge-application-management:instances:delete"
        )
    ),
    "POST /edge-application-management/v0.1alpha1/deployments": OperationRequirements(
        "app-provider", False
        "app-provider", False, scope="edge-application-management:deployments:write"
    ),
    "GET /edge-application-management/v0.1alpha1/deployments": OperationRequirements(
        "app-provider", False
        "app-provider", False, scope="edge-application-management:deployments:read"
    ),
    "DELETE /edge-application-management/v0.1alpha1/deployments/{appDeploymentId}": (
        OperationRequirements("app-provider", False)
        OperationRequirements(
            "app-provider", False, scope="edge-application-management:deployments:delete"
        )
    ),
    "PATCH /edge-application-management/v0.1alpha1/deployments/{appDeploymentId}": (
        OperationRequirements("app-provider", False)
        OperationRequirements(
            "app-provider", False, scope="edge-application-management:deployments:update"
        )
    ),
    "GET /edge-application-management/v0.1alpha1/clusters": OperationRequirements(
        "app-provider", False
        "app-provider", False, scope="edge-application-management:clusters:read"
    ),
    # Location Retrieval -- locates a real end user's device, so a role-correct
    # app-provider service-account token is not enough: this always requires a
    # three-legged (user-consented) token, regardless of role/audience validity.
    "POST /location-retrieval/v0.5/retrieve": OperationRequirements("app-provider", True),
    # Scope matches location-retrieval.yaml's `openId` security block.
    "POST /location-retrieval/v0.5/retrieve": OperationRequirements(
        "app-provider", True, scope="location-retrieval:read"
    ),
}


@@ -135,3 +167,28 @@ def require_three_legged(
            message="This operation requires a three-legged (user-consented) access token"
        )
    return caller


def require_scope(
    request: Request,
    caller: Annotated[CallerContext, Depends(get_caller_context)],
    settings: Annotated[Settings, Depends(get_settings)],
) -> CallerContext:
    requirements = _requirements_for(request)
    if requirements.scope is None:
        return caller
    if caller.flow == TokenFlow.TWO_LEGGED and not requirements.scope_required_for_two_legged:
        return caller
    if requirements.scope not in caller.scopes:
        if not settings.keycloak_settings.enforcement_enabled:
            logger.warning(
                "authz_would_reject",
                reason="missing_scope",
                required_scope=requirements.scope,
                path=request.url.path,
            )
            return caller
        raise ForbiddenException(
            message=f"Caller is missing the required scope: {requirements.scope}"
        )
    return caller
+5 −0
Changes for src/open_exposure_gateway/dependencies.py: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -71,6 +71,9 @@ class CallerContext:
    # Location Retrieval derive `device` from the token instead of requiring it
    # in the request body (closes architecture.md F4).
    subject_id: Optional[str]
    # The token's `scope` claim, space-separated per RFC 6749/Keycloak convention,
    # split into individual CAMARA operation scopes (e.g. "qod-sessions-write").
    scopes: list[str]


def get_app_state(request: Request) -> AppState:
@@ -107,6 +110,7 @@ def _unverified_caller_context(x_correlator: Optional[str]) -> CallerContext:
        roles=[],
        flow=TokenFlow.TWO_LEGGED,
        subject_id=None,
        scopes=[],
    )


@@ -149,6 +153,7 @@ async def get_caller_context(
        roles=roles,
        flow=decoded.flow,
        subject_id=claims["sub"] if decoded.flow == TokenFlow.THREE_LEGGED else None,
        scopes=claims.get("scope", "").split(),
    )


Loading