Loading .env.example +3 −0 Changes for .env.example: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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" api-products.example.json 0 → 100644 +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" ] } ] } src/portal_service/config.py +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 Loading Loading @@ -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="__") Loading @@ -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": Loading src/portal_service/domain/api_products.py 0 → 100644 +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} src/portal_service/main.py +10 −0 Changes for src/portal_service/main.py: 10 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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__) Loading @@ -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 Loading
.env.example +3 −0 Changes for .env.example: 3 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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"
api-products.example.json 0 → 100644 +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" ] } ] }
src/portal_service/config.py +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 Loading Loading @@ -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="__") Loading @@ -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": Loading
src/portal_service/domain/api_products.py 0 → 100644 +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}
src/portal_service/main.py +10 −0 Changes for src/portal_service/main.py: 10 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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__) Loading @@ -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