Commit 631fd1b1 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: add API products configuration and validation, including loading from JSON

parent 97937f5d
Loading
Loading
Loading
Loading
+3 −0
Changes for .env.example: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -13,3 +13,6 @@ OIDC__REDIRECT_URI="http://localhost:3000/api/auth/callback"

# OEG, which the GUI reaches through the portal at /api/oeg/* with the user's token.
OEG__BASE_URL="http://localhost:8080"

# API products and their scopes.
API_PRODUCTS__FILE="api-products.example.json"
+39 −0
Changes for api-products.example.json: 39 added lines, 0 removed lines.
Original line number Diff line number Diff line
{
  "products": [
    {
      "name": "edge-application-management",
      "scopes": [
        "edge-application-management:apps:read",
        "edge-application-management:apps:write",
        "edge-application-management:apps:delete",
        "edge-application-management:instances:read",
        "edge-application-management:instances:write",
        "edge-application-management:instances:delete",
        "edge-application-management:deployments:read",
        "edge-application-management:deployments:write",
        "edge-application-management:deployments:update",
        "edge-application-management:deployments:delete",
        "edge-application-management:clusters:read",
        "edge-application-management:edge-cloud-zones:read"
      ]
    },
    {
      "name": "quality-on-demand",
      "scopes": ["qod-sessions-read", "qod-sessions-write", "qod-sessions-delete"]
    },
    {
      "name": "location-retrieval",
      "scopes": ["location-retrieval:read"]
    },
    {
      "name": "traffic-influence",
      "scopes": [
        "traffic-influence:traffic-influences:read",
        "traffic-influence:traffic-influences:write",
        "traffic-influence:traffic-influence-devices:write",
        "traffic-influence:traffic-influences:update",
        "traffic-influence:traffic-influences:delete"
      ]
    }
  ]
}
+6 −0
Changes for src/portal_service/config.py: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
from enum import StrEnum
from functools import lru_cache
from pathlib import Path

from pydantic import BaseModel, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -87,6 +88,10 @@ class OEGSettings(BaseModel):
    timeout: float = 30.0


class ApiProductsSettings(BaseModel):
    file: Path


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__")

@@ -101,6 +106,7 @@ class Settings(BaseSettings):
    cleanup: CleanupSettings = CleanupSettings()
    cors: CORSSettings = CORSSettings()
    oeg: OEGSettings = OEGSettings()
    api_products: ApiProductsSettings

    @model_validator(mode="after")
    def oidc_required_for_jwt_mode(self) -> "Settings":
+46 −0
Changes for src/portal_service/domain/api_products.py: 46 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""API products: the CAMARA APIs offered as units, and the scopes each one grants."""

from collections import Counter
from collections.abc import Iterable

from pydantic import BaseModel, Field, model_validator


class ApiProduct(BaseModel):
    name: str = Field(min_length=1)
    scopes: frozenset[str] = Field(min_length=1)


class ApiProductCatalog(BaseModel):
    products: tuple[ApiProduct, ...] = Field(min_length=1)

    @model_validator(mode="after")
    def names_and_scopes_are_unique(self) -> "ApiProductCatalog":
        duplicate_names = _repeated(product.name for product in self.products)
        if duplicate_names:
            raise ValueError(f"API products listed twice: {sorted(duplicate_names)}")
        shared_scopes = _repeated(scope for product in self.products for scope in product.scopes)
        if shared_scopes:
            raise ValueError(f"scopes in more than one API product: {sorted(shared_scopes)}")
        return self

    @property
    def names(self) -> set[str]:
        return {product.name for product in self.products}

    def scopes_for(self, names: Iterable[str]) -> set[str]:
        by_name = {product.name: product for product in self.products}
        requested = set(names)
        unknown = requested - by_name.keys()
        if unknown:
            raise ValueError(f"unknown API products: {sorted(unknown)}")
        return {scope for name in requested for scope in by_name[name].scopes}

    def products_in(self, scopes: Iterable[str]) -> set[str]:
        """Products whose every scope is present; a partial set grants nothing."""
        held = set(scopes)
        return {product.name for product in self.products if product.scopes <= held}


def _repeated(values: Iterable[str]) -> set[str]:
    return {value for value, count in Counter(values).items() if count > 1}
+10 −0
Changes for src/portal_service/main.py: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ from portal_service.api.middlewares import register_middlewares
from portal_service.api.oeg.routes import oeg_router
from portal_service.background import cleanup_loop
from portal_service.config import SecurityMode, get_settings
from portal_service.domain.api_products import ApiProductCatalog

logger: structlog.BoundLogger = structlog.get_logger(__name__)

@@ -44,6 +45,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
        version=settings.app_version,
    )

    try:
        app.state.api_products = ApiProductCatalog.model_validate_json(
            settings.api_products.file.read_text()
        )
        logger.info("api products loaded", products=sorted(app.state.api_products.names))
    except (OSError, ValueError) as e:
        logger.error("api products load failed", file=str(settings.api_products.file), error=str(e))
        raise

    try:
        db_engine, session_maker = await build_engine_and_session_maker(
            url=settings.postgresql_settings.url,
Loading