diff --git a/.github/workflows/build-publish.yml b/.github/workflows/build-publish.yml index df1366b..8b622d3 100644 --- a/.github/workflows/build-publish.yml +++ b/.github/workflows/build-publish.yml @@ -35,7 +35,7 @@ jobs: { "arg": "FASTAPI_COMMON_REF", "repo": "https://github.com/openg2p/openg2p-fastapi-common", - "ref": "1.1" + "ref": "develop" } ] }, @@ -47,7 +47,7 @@ jobs: { "arg": "FASTAPI_COMMON_REF", "repo": "https://github.com/openg2p/openg2p-fastapi-common", - "ref": "1.1" + "ref": "develop" } ] }, @@ -59,7 +59,7 @@ jobs: { "arg": "FASTAPI_COMMON_REF", "repo": "https://github.com/openg2p/openg2p-fastapi-common", - "ref": "1.1" + "ref": "develop" } ] } diff --git a/.github/workflows/iam-core-test.yml b/.github/workflows/iam-core-test.yml index 2ef855d..3f9eb4a 100644 --- a/.github/workflows/iam-core-test.yml +++ b/.github/workflows/iam-core-test.yml @@ -19,7 +19,7 @@ on: inputs: fastapi_common_ref: description: "openg2p-fastapi-common git ref (tag/branch)" - default: "1.1" + default: "develop" type: string concurrency: @@ -50,7 +50,7 @@ jobs: python -m pip install --upgrade pip # External lib (openg2p-fastapi-common) from git. python -m pip install \ - "git+https://github.com/openg2p/openg2p-fastapi-common@${{ inputs.fastapi_common_ref || '1.1' }}#subdirectory=openg2p-fastapi-common" + "git+https://github.com/openg2p/openg2p-fastapi-common@${{ inputs.fastapi_common_ref || 'develop' }}#subdirectory=openg2p-fastapi-common" # In-repo library (not on PyPI) + test tooling. python -m pip install -e ./iam-core python -m pip install pytest pytest-asyncio diff --git a/.github/workflows/iam-staff-portal-api-test.yml b/.github/workflows/iam-staff-portal-api-test.yml index 8857bc0..1232ebb 100644 --- a/.github/workflows/iam-staff-portal-api-test.yml +++ b/.github/workflows/iam-staff-portal-api-test.yml @@ -19,7 +19,7 @@ on: inputs: fastapi_common_ref: description: "openg2p-fastapi-common git ref (tag/branch)" - default: "1.1" + default: "develop" type: string concurrency: @@ -49,7 +49,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install \ - "git+https://github.com/openg2p/openg2p-fastapi-common@${{ inputs.fastapi_common_ref || '1.1' }}#subdirectory=openg2p-fastapi-common" + "git+https://github.com/openg2p/openg2p-fastapi-common@${{ inputs.fastapi_common_ref || 'develop' }}#subdirectory=openg2p-fastapi-common" python -m pip install -e "./iam-core[dev]" python -m pip install -e "./iam-staff-portal-api[dev]" - name: Run test suite diff --git a/deployments/charts/openg2p-iam-service/values.yaml b/deployments/charts/openg2p-iam-service/values.yaml index 5860d63..c6c93db 100644 --- a/deployments/charts/openg2p-iam-service/values.yaml +++ b/deployments/charts/openg2p-iam-service/values.yaml @@ -143,6 +143,11 @@ iamStaffPortalApi: IAM_STAFF_DB_DBNAME: '{{ tpl .Values.global.iamDB $ }}' IAM_STAFF_DB_PORT: '{{ .Values.global.iamDBPort }}' IAM_STAFF_DB_USERNAME: '{{ tpl .Values.global.iamDBUser $ }}' + # SQLAlchemy pool per gunicorn worker process. + IAM_STAFF_DB_POOL_SIZE: 5 + IAM_STAFF_DB_POOL_MAX_OVERFLOW: 10 + IAM_STAFF_DB_POOL_PRE_PING: true + IAM_STAFF_DB_POOL_RECYCLE: 1800 IAM_STAFF_OPENAPI_ROOT_PATH: '{{ .Values.openapiRootPath }}' IAM_STAFF_KEYCLOAK_BASE_URL: '{{ .Values.global.keycloakBaseUrl }}' IAM_STAFF_KEYCLOAK_ISSUER_URL: '{{ tpl .Values.global.keycloakExternalIssuerUrl $ }}' diff --git a/iam-core/pyproject.toml b/iam-core/pyproject.toml index 9d513a2..64595de 100644 --- a/iam-core/pyproject.toml +++ b/iam-core/pyproject.toml @@ -15,7 +15,6 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "openg2p-fastapi-common", "cryptography >=41.0.4", "python-jose >=3.3.0", "httpx", diff --git a/iam-core/src/iam_core/context.py b/iam-core/src/iam_core/context.py index ed37ae9..5089168 100644 --- a/iam-core/src/iam_core/context.py +++ b/iam-core/src/iam_core/context.py @@ -1,9 +1,6 @@ from contextvars import ContextVar from typing import Any, Dict, List, Optional -jwks_cache: ContextVar[dict] = ContextVar("jwks_cache", default=None) -server_metadata_cache: ContextVar[dict] = ContextVar("server_metadata_cache", default=None) - auth_id_type_config_cache: ContextVar[Optional[Dict[str, Any]]] = ContextVar( "auth_id_type_config_cache", default=None ) diff --git a/iam-core/src/iam_core/user_auth/app.py b/iam-core/src/iam_core/user_auth/app.py index c501915..25ddf90 100644 --- a/iam-core/src/iam_core/user_auth/app.py +++ b/iam-core/src/iam_core/user_auth/app.py @@ -16,12 +16,15 @@ from iam_core.user_auth.adapters.implementations.esignet_adapter import EsignetAdapter from iam_core.user_auth.adapters.adapter_factory import AdapterFactory from iam_core.partner_auth.jwt_validation_helper import JWTValidationHelper +from .cache import init_cache class Initializer(BaseInitializer): def initialize(self, **kwargs): super().initialize() + init_cache() + # Adapters OIDCBase() KeycloakAdapter() diff --git a/iam-core/src/iam_core/user_auth/cache.py b/iam-core/src/iam_core/user_auth/cache.py new file mode 100644 index 0000000..af7ec62 --- /dev/null +++ b/iam-core/src/iam_core/user_auth/cache.py @@ -0,0 +1,6 @@ +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend + + +def init_cache() -> None: + FastAPICache.init(InMemoryBackend(), prefix="iam-cache") diff --git a/iam-core/src/iam_core/user_auth/config.py b/iam-core/src/iam_core/user_auth/config.py index d3de56f..a0c24a5 100644 --- a/iam-core/src/iam_core/user_auth/config.py +++ b/iam-core/src/iam_core/user_auth/config.py @@ -51,3 +51,8 @@ class Settings(BaseSettings): # Client-side cache for role→permissions resolution (ResolvePermissionMiddleware). # Same roles share one entry across users; short TTL so IAM role mapping changes propagate. auth_permissions_cache_ttl_seconds: int = 60 + + # Process-wide caches for IdP discovery (OidcClient / JWKS). + # ContextVar cannot be used here — it is request-scoped and would refetch every call. + auth_oidc_metadata_cache_ttl_seconds: int = 60 * 5 + auth_jwks_cache_ttl_seconds: int = 60 * 5 diff --git a/iam-core/src/iam_core/user_auth/helpers/jwks_helper.py b/iam-core/src/iam_core/user_auth/helpers/jwks_helper.py index 9f9be42..7f2b035 100644 --- a/iam-core/src/iam_core/user_auth/helpers/jwks_helper.py +++ b/iam-core/src/iam_core/user_auth/helpers/jwks_helper.py @@ -1,21 +1,36 @@ import logging import httpx +from fastapi_cache.coder import PickleCoder +from fastapi_cache.decorator import cache from openg2p_fastapi_common.errors.http_exceptions import InternalServerError -from iam_core.context import jwks_cache from iam_core.user_auth.config import Settings _config = Settings.get_config(strict=False) _logger = logging.getLogger(_config.logging_default_logger_name) +def _jwks_key_builder(func, namespace: str, *args, **kwargs) -> str: + """Cache by issuer (same as the old ContextVar), falling back to jwks_uri.""" + call_args = kwargs.get("args") or () + call_kwargs = kwargs.get("kwargs") or {} + metadata = call_args[0] if call_args else call_kwargs.get("metadata") or {} + if len(call_args) > 1: + issuer = call_args[1] + else: + issuer = call_kwargs.get("issuer") + jwks_uri = metadata.get("jwks_uri") if isinstance(metadata, dict) else None + return f"{namespace}:jwks:{issuer or jwks_uri or 'missing'}" + + +@cache( + expire=_config.auth_jwks_cache_ttl_seconds, + key_builder=_jwks_key_builder, + coder=PickleCoder, +) async def get_jwks(metadata: dict, issuer: str | None = None) -> dict: - """Fetch and cache JWKS for the given OIDC metadata. Uses issuer for cache key.""" - cache = jwks_cache.get() or {} - if issuer and issuer in cache: - return cache[issuer] - + """Fetch JWKS for the given OIDC metadata. Cached process-wide by issuer.""" jwks_url = metadata.get("jwks_uri") if not jwks_url and issuer: jwks_url = f"{issuer.rstrip('/')}/.well-known/jwks.json" @@ -28,9 +43,4 @@ async def get_jwks(metadata: dict, issuer: str | None = None) -> dict: async with httpx.AsyncClient(verify=_config.auth_verify_ssl, timeout=10) as client: response = await client.get(jwks_url) response.raise_for_status() - jwks = response.json() - - if issuer: - cache[issuer] = jwks - jwks_cache.set(cache) - return jwks + return response.json() diff --git a/iam-core/src/iam_core/user_auth/oidc_client.py b/iam-core/src/iam_core/user_auth/oidc_client.py index a419011..8979f27 100644 --- a/iam-core/src/iam_core/user_auth/oidc_client.py +++ b/iam-core/src/iam_core/user_auth/oidc_client.py @@ -5,10 +5,11 @@ import httpx from authlib.integrations.httpx_client import AsyncOAuth2Client from authlib.oauth2.rfc7636 import create_s256_code_challenge +from fastapi_cache.coder import PickleCoder +from fastapi_cache.decorator import cache from jose import jwt as jose_jwt from openg2p_fastapi_common.errors.http_exceptions import InternalServerError, UnauthorizedError -from iam_core.context import server_metadata_cache from iam_core.models import LoginProvider from iam_core.schemas import TokenEndpointAuthMethod @@ -26,6 +27,15 @@ _logger = logging.getLogger(_config.logging_default_logger_name) +def _server_metadata_key_builder(func, namespace: str, *args, **kwargs) -> str: + """Cache by login-provider id so the ORM object itself is not part of the key.""" + call_args = kwargs.get("args") or () + call_kwargs = kwargs.get("kwargs") or {} + login_provider = call_args[1] if len(call_args) > 1 else call_kwargs.get("login_provider") + provider_id = getattr(login_provider, "id", None) + return f"{namespace}:oidc_server_metadata:{provider_id}" + + class OidcClient: @staticmethod def _extra_params(login_provider: LoginProvider) -> dict: @@ -71,12 +81,12 @@ def _metadata_url(cls, login_provider: LoginProvider) -> str | None: return None return f"{issuer.rstrip('/')}/.well-known/openid-configuration" + @cache( + expire=_config.auth_oidc_metadata_cache_ttl_seconds, + key_builder=_server_metadata_key_builder, + coder=PickleCoder, + ) async def get_server_metadata(self, login_provider: LoginProvider) -> dict: - cache = server_metadata_cache.get() or {} - cache_key = f"lp:{login_provider.id}" - if cache_key in cache: - return cache[cache_key] - metadata_url = self._metadata_url(login_provider) metadata = {} if metadata_url: @@ -94,8 +104,6 @@ async def get_server_metadata(self, login_provider: LoginProvider) -> dict: if login_provider.jwks_uri: metadata["jwks_uri"] = login_provider.jwks_uri - cache[cache_key] = metadata - server_metadata_cache.set(cache) return metadata async def build_authorize_redirect( diff --git a/iam-core/tests/test_helpers_and_middleware.py b/iam-core/tests/test_helpers_and_middleware.py index 9369938..6e9f93a 100644 --- a/iam-core/tests/test_helpers_and_middleware.py +++ b/iam-core/tests/test_helpers_and_middleware.py @@ -6,6 +6,8 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend from jose import jwt as jose_jwt from openg2p_fastapi_common.errors.http_exceptions import ( ForbiddenError, @@ -14,7 +16,6 @@ ) from starlette.routing import Match -from iam_core.context import jwks_cache from iam_core.schemas import TokenEndpointAuthMethod from iam_core.user_auth.decorators import ( endpoint_requires_auth, @@ -235,7 +236,8 @@ def test_logout_token_helper_validation_paths(): @pytest.mark.asyncio async def test_jwks_helper_fetch_and_cache(): - jwks_cache.set(None) + FastAPICache.init(InMemoryBackend(), prefix="test-jwks") + await FastAPICache.clear() metadata = {"jwks_uri": "https://issuer/jwks"} response = MagicMock() response.raise_for_status = MagicMock() diff --git a/iam-core/tests/test_oidc_and_adapters.py b/iam-core/tests/test_oidc_and_adapters.py index 3ccd770..5d8b8e3 100644 --- a/iam-core/tests/test_oidc_and_adapters.py +++ b/iam-core/tests/test_oidc_and_adapters.py @@ -4,10 +4,11 @@ import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi_cache import FastAPICache +from fastapi_cache.backends.inmemory import InMemoryBackend from jose import jwt as jose_jwt from openg2p_fastapi_common.errors.http_exceptions import InternalServerError, UnauthorizedError -from iam_core.context import server_metadata_cache from iam_core.schemas import TokenEndpointAuthMethod from iam_core.user_auth.adapters.adapter_factory import AdapterFactory from iam_core.user_auth.adapters.implementations.esignet_adapter import EsignetAdapter @@ -185,7 +186,8 @@ def test_oidc_client_guess_issuer_from_endpoints(): @pytest.mark.asyncio async def test_oidc_client_get_server_metadata_fetches_and_caches(): - server_metadata_cache.set(None) + FastAPICache.init(InMemoryBackend(), prefix="test-oidc-metadata") + await FastAPICache.clear() lp = make_login_provider(server_metadata_url="https://idp/.well-known/openid-configuration") client = OidcClient() metadata_response = MagicMock() diff --git a/iam-core/tests/test_user_auth_app.py b/iam-core/tests/test_user_auth_app.py index 6250d40..96563b4 100644 --- a/iam-core/tests/test_user_auth_app.py +++ b/iam-core/tests/test_user_auth_app.py @@ -18,6 +18,7 @@ def test_initializer_registers_auth_components(): patch("iam_core.user_auth.app.RedisRefreshTokenStore") as redis_refresh_token_store, patch("iam_core.user_auth.app.TokenValidatorService") as token_validator_service, patch("iam_core.user_auth.app.JWTValidationHelper") as jwt_validation_helper, + patch("iam_core.user_auth.app.init_cache") as init_cache, ): Initializer.initialize(init) @@ -31,3 +32,4 @@ def test_initializer_registers_auth_components(): redis_refresh_token_store.assert_called_once() token_validator_service.assert_called_once() jwt_validation_helper.assert_called_once() + init_cache.assert_called_once() diff --git a/iam-staff-portal-api/.env.example b/iam-staff-portal-api/.env.example index c4f60d4..2ff94a1 100644 --- a/iam-staff-portal-api/.env.example +++ b/iam-staff-portal-api/.env.example @@ -9,6 +9,11 @@ IAM_STAFF_DB_USERNAME=postgres IAM_STAFF_DB_PASSWORD=password IAM_STAFF_DB_DBNAME=iam_staff IAM_STAFF_DB_PORT=5432 +# SQLAlchemy pool per process. Defaults are 5 / 10. +IAM_STAFF_DB_POOL_SIZE=5 +IAM_STAFF_DB_POOL_MAX_OVERFLOW=10 +IAM_STAFF_DB_POOL_PRE_PING=true +IAM_STAFF_DB_POOL_RECYCLE=1800 IAM_STAFF_OPENAPI_ROOT_PATH=/ diff --git a/iam-staff-portal-api/pyproject.toml b/iam-staff-portal-api/pyproject.toml index 6563230..d762cb4 100644 --- a/iam-staff-portal-api/pyproject.toml +++ b/iam-staff-portal-api/pyproject.toml @@ -15,7 +15,6 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "openg2p-fastapi-common", "iam-core", "fastapi-cache2", "python-slugify>=8.0.0", diff --git a/iam-staff-portal-api/src/iam_staff_portal_api/controllers/data_policy_controller.py b/iam-staff-portal-api/src/iam_staff_portal_api/controllers/data_policy_controller.py index bbfde55..896af24 100644 --- a/iam-staff-portal-api/src/iam_staff_portal_api/controllers/data_policy_controller.py +++ b/iam-staff-portal-api/src/iam_staff_portal_api/controllers/data_policy_controller.py @@ -4,9 +4,7 @@ from fastapi import Request from fastapi_cache.decorator import cache -from sqlalchemy.ext.asyncio import async_sessionmaker - -from openg2p_fastapi_common.context import dbengine +from openg2p_fastapi_common.context import get_async_session_maker from ..config import Settings from iam_core.schemas.data_policy import ( AddPolicyRequest, @@ -88,7 +86,7 @@ async def get_policy(self, request: GetPolicyRequest) -> GetPolicyResponse: payload = request.request_body.request_payload _logger.info("Getting data policy policy_id=%s", payload.policy_id) - session_maker = async_sessionmaker(dbengine.get(), expire_on_commit=False) + session_maker = get_async_session_maker() async with session_maker() as session: policy = await self._service.get_policy( session, @@ -115,7 +113,7 @@ async def get_all_policies(self, request: GetAllPoliciesRequest) -> GetAllPolici payload.register_id, ) - session_maker = async_sessionmaker(dbengine.get(), expire_on_commit=False) + session_maker = get_async_session_maker() async with session_maker() as session: policies, total = await self._service.get_all_policies( session, @@ -156,7 +154,7 @@ async def add_policy(self, request: AddPolicyRequest, http_request: Request) -> if payload.application_id is not None and not mnemonic.startswith("DP_"): mnemonic = f"DP_{mnemonic}" - session_maker = async_sessionmaker(dbengine.get(), expire_on_commit=False) + session_maker = get_async_session_maker() async with session_maker() as session: policy = await self._service.add_policy( policy_mnemonic=mnemonic, @@ -213,7 +211,7 @@ async def remove_policy( payload = request.request_body.request_payload _logger.info("Removing data policy policy_id=%s", payload.policy_id) - session_maker = async_sessionmaker(dbengine.get(), expire_on_commit=False) + session_maker = get_async_session_maker() async with session_maker() as session: # Get policy first to check if it's an IAM application policy from iam_core.models import DataPolicy @@ -283,7 +281,7 @@ async def _evaluate_expression_cached( policy_mnemonics: list[str], ) -> list[DataPolicyData]: """Internal cached method for expression evaluation.""" - session_maker = async_sessionmaker(dbengine.get(), expire_on_commit=False) + session_maker = get_async_session_maker() async with session_maker() as session: policies = await self._service.get_policies_by_mnemonics( policy_mnemonics=policy_mnemonics, diff --git a/iam-staff-portal-api/src/iam_staff_portal_api/controllers/user_access_controller.py b/iam-staff-portal-api/src/iam_staff_portal_api/controllers/user_access_controller.py index fb7f762..c1a3ddd 100644 --- a/iam-staff-portal-api/src/iam_staff_portal_api/controllers/user_access_controller.py +++ b/iam-staff-portal-api/src/iam_staff_portal_api/controllers/user_access_controller.py @@ -2,12 +2,10 @@ from fastapi import Request from fastapi_cache.decorator import cache -from openg2p_fastapi_common.context import dbengine +from openg2p_fastapi_common.context import get_async_session_maker from openg2p_fastapi_common.controller import BaseController from openg2p_fastapi_common.errors.http_exceptions import BadRequestError from sqlalchemy import delete, select -from sqlalchemy.ext.asyncio import async_sessionmaker - from iam_core.user_auth.decorators import requires_auth from ..cache import role_cache_key @@ -76,7 +74,7 @@ async def get_staff_portal_applications( client_roles = auth.client_roles or {} allowed_mnemonics = list(client_roles.keys()) - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: stmt = ( select(StaffPortalApplication) @@ -124,7 +122,7 @@ async def register_staff_portal_application( instances of the same product coexist by each using a distinct mnemonic/client_id and pushing their own (identical) catalog under it. """ - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: # Seed dumps / manual SQL can leave SERIAL sequences behind MAX(id). # Sync before any inserts so self-registration (farmer-registry, @@ -295,7 +293,7 @@ async def get_application_permissions_for_user( client_roles_items = client_roles.items() result = [] - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: for client_id, roles in client_roles_items: stmt = select(StaffPortalApplication).where( @@ -361,7 +359,7 @@ async def get_permission_mnemonics_for_role( self, role_mnemonic: str, ) -> List[str]: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: role_stmt = select(StaffRole).where( StaffRole.role_mnemonic == role_mnemonic, diff --git a/iam-staff-portal-api/src/iam_staff_portal_api/data/data_loader.py b/iam-staff-portal-api/src/iam_staff_portal_api/data/data_loader.py index b260b9d..693f579 100644 --- a/iam-staff-portal-api/src/iam_staff_portal_api/data/data_loader.py +++ b/iam-staff-portal-api/src/iam_staff_portal_api/data/data_loader.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from iam_core.models import LoginProvider -from openg2p_fastapi_common.context import dbengine +from openg2p_fastapi_common.context import async_session_maker, dbengine, get_async_session_maker from ..config import Settings from ..models import ( @@ -403,7 +403,10 @@ async def sync_staff_access_id_sequences(self, session: AsyncSession) -> None: await self.sync_postgres_id_sequences(session, STAFF_ACCESS_SEQUENCE_MODELS) def create_session_factory(self) -> async_sessionmaker[AsyncSession]: - return async_sessionmaker(dbengine.get(), expire_on_commit=False) + # Dispose (in run()) can leave the process-wide factory bound to a dead + # pool; drop it so the next checkout opens connections on this event loop. + async_session_maker.set(None) + return get_async_session_maker() class DataLoader(DataLoaderBase): diff --git a/iam-staff-portal-api/src/iam_staff_portal_api/services/application_access_service.py b/iam-staff-portal-api/src/iam_staff_portal_api/services/application_access_service.py index 3ae0f31..5894b96 100644 --- a/iam-staff-portal-api/src/iam_staff_portal_api/services/application_access_service.py +++ b/iam-staff-portal-api/src/iam_staff_portal_api/services/application_access_service.py @@ -2,12 +2,10 @@ import logging -from openg2p_fastapi_common.context import dbengine +from openg2p_fastapi_common.context import get_async_session_maker from openg2p_fastapi_common.errors.http_exceptions import BadRequestError, NotFoundError from openg2p_fastapi_common.service import BaseService from sqlalchemy import delete, select -from sqlalchemy.ext.asyncio import async_sessionmaker - from ..helpers.query_helper import dt_iso, paginate from ..helpers.keycloak_helper import KeycloakHelper @@ -67,7 +65,7 @@ def _perm_data(self, perm: StaffApplicationPermission) -> PermissionData: async def get_roles( self, payload: ApplicationScopedPayload, page: int, page_size: int ) -> tuple[list[RoleData], int]: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: await self._get_application(session, payload.application_id) stmt = ( @@ -84,7 +82,7 @@ async def create_role(self, payload: RoleCreatePayload, auth_token: str = "") -> mnemonic = payload.role_mnemonic.strip() if not mnemonic: raise BadRequestError(message="role_mnemonic is required") - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: await self._get_application(session, payload.application_id) existing = ( @@ -133,7 +131,7 @@ async def create_role(self, payload: RoleCreatePayload, auth_token: str = "") -> return self._role_data(role) async def delete_role(self, payload: RoleDeletePayload, auth_token: str = "") -> RoleData: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: await self._get_application(session, payload.application_id) role = await session.get(StaffRole, payload.id) @@ -159,7 +157,7 @@ async def delete_role(self, payload: RoleDeletePayload, auth_token: str = "") -> async def get_permissions( self, payload: ApplicationScopedPayload, page: int, page_size: int ) -> tuple[list[PermissionData], int]: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: await self._get_application(session, payload.application_id) stmt = ( @@ -174,7 +172,7 @@ async def create_permission(self, payload: PermissionCreatePayload) -> Permissio mnemonic = payload.permission_mnemonic.strip() if not mnemonic: raise BadRequestError(message="permission_mnemonic is required") - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: await self._get_application(session, payload.application_id) existing = ( @@ -203,7 +201,7 @@ async def create_permission(self, payload: PermissionCreatePayload) -> Permissio return self._perm_data(perm) async def delete_permission(self, payload: PermissionDeletePayload) -> PermissionData: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: await self._get_application(session, payload.application_id) perm = await session.get(StaffApplicationPermission, payload.id) @@ -220,7 +218,7 @@ async def delete_permission(self, payload: PermissionDeletePayload) -> Permissio async def get_role_permissions( self, payload: RolePermissionListPayload, page: int, page_size: int ) -> tuple[list[RolePermissionData], int]: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: await self._get_application(session, payload.application_id) roles = ( @@ -275,7 +273,7 @@ async def get_role_permissions( return items, total async def create_role_permission(self, payload: RolePermissionCreatePayload) -> RolePermissionData: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: await self._get_application(session, payload.application_id) role = await session.get(StaffRole, payload.role_id) @@ -322,7 +320,7 @@ async def create_role_permission(self, payload: RolePermissionCreatePayload) -> ) async def delete_role_permission(self, payload: RolePermissionDeletePayload) -> RolePermissionData: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: await self._get_application(session, payload.application_id) mapping = await session.get(StaffRolePermission, payload.id) diff --git a/iam-staff-portal-api/src/iam_staff_portal_api/services/applications_service.py b/iam-staff-portal-api/src/iam_staff_portal_api/services/applications_service.py index 298a204..2381699 100644 --- a/iam-staff-portal-api/src/iam_staff_portal_api/services/applications_service.py +++ b/iam-staff-portal-api/src/iam_staff_portal_api/services/applications_service.py @@ -1,11 +1,9 @@ from __future__ import annotations -from openg2p_fastapi_common.context import dbengine +from openg2p_fastapi_common.context import get_async_session_maker from openg2p_fastapi_common.errors.http_exceptions import BadRequestError, NotFoundError from openg2p_fastapi_common.service import BaseService from sqlalchemy import delete, select -from sqlalchemy.ext.asyncio import async_sessionmaker - from ..helpers.query_helper import dt_iso, paginate from ..helpers.keycloak_helper import KeycloakHelper from ..models import StaffPortalApplication @@ -35,7 +33,7 @@ def _to_data(self, app: StaffPortalApplication) -> ApplicationData: ) async def get_applications(self, page: int, page_size: int) -> tuple[list[ApplicationData], int]: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: stmt = select(StaffPortalApplication).order_by( StaffPortalApplication.created_at.desc().nullslast(), @@ -45,7 +43,7 @@ async def get_applications(self, page: int, page_size: int) -> tuple[list[Applic return [self._to_data(r) for r in rows], total async def get_application(self, payload: ApplicationIdPayload) -> ApplicationData: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: app = await session.get(StaffPortalApplication, payload.id) if app is None: @@ -59,7 +57,7 @@ async def create_application( if not mnemonic: raise BadRequestError(message="application_mnemonic is required") - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: existing = ( ( @@ -109,7 +107,7 @@ async def create_application( return self._to_data(app) async def update_application(self, payload: ApplicationUpdatePayload) -> ApplicationData: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: app = await session.get(StaffPortalApplication, payload.id) if app is None: @@ -135,7 +133,7 @@ async def update_application(self, payload: ApplicationUpdatePayload) -> Applica async def delete_application( self, payload: ApplicationDeletePayload, auth_token: str = "" ) -> ApplicationData: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: app = await session.get(StaffPortalApplication, payload.id) if app is None: diff --git a/iam-staff-portal-api/src/iam_staff_portal_api/services/login_providers_service.py b/iam-staff-portal-api/src/iam_staff_portal_api/services/login_providers_service.py index e6f4ae5..232bff4 100644 --- a/iam-staff-portal-api/src/iam_staff_portal_api/services/login_providers_service.py +++ b/iam-staff-portal-api/src/iam_staff_portal_api/services/login_providers_service.py @@ -1,11 +1,9 @@ from __future__ import annotations -from openg2p_fastapi_common.context import dbengine +from openg2p_fastapi_common.context import get_async_session_maker from openg2p_fastapi_common.errors.http_exceptions import BadRequestError, NotFoundError from openg2p_fastapi_common.service import BaseService from sqlalchemy import select -from sqlalchemy.ext.asyncio import async_sessionmaker - from iam_core.models import LoginProvider from ..helpers.query_helper import dt_iso, paginate @@ -56,14 +54,14 @@ def _to_data(self, provider: LoginProvider) -> LoginProviderData: ) async def get_login_providers(self, page: int, page_size: int) -> tuple[list[LoginProviderData], int]: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: stmt = select(LoginProvider).order_by(LoginProvider.id.desc()) rows, total = await paginate(session, stmt, page=page, page_size=page_size) return [self._to_data(r) for r in rows], total async def get_login_provider(self, payload: LoginProviderIdPayload) -> LoginProviderData: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: provider = await session.get(LoginProvider, payload.id) if provider is None: @@ -80,7 +78,7 @@ async def create_login_provider(self, payload: LoginProviderCreatePayload) -> Lo if not payload.oauth_callback_url.strip(): raise BadRequestError(message="oauth_callback_url is required") - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: provider = LoginProvider( provider_name=payload.provider_name.strip(), @@ -112,7 +110,7 @@ async def create_login_provider(self, payload: LoginProviderCreatePayload) -> Lo return self._to_data(provider) async def update_login_provider(self, payload: LoginProviderUpdatePayload) -> LoginProviderData: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: provider = await session.get(LoginProvider, payload.id) if provider is None: @@ -153,7 +151,7 @@ async def update_login_provider(self, payload: LoginProviderUpdatePayload) -> Lo return self._to_data(provider) async def delete_login_provider(self, payload: LoginProviderDeletePayload) -> LoginProviderData: - async_session = async_sessionmaker(dbengine.get()) + async_session = get_async_session_maker() async with async_session() as session: provider = await session.get(LoginProvider, payload.id) if provider is None: diff --git a/iam-staff-portal-api/tests/helpers.py b/iam-staff-portal-api/tests/helpers.py index 7c9befe..a3e42ad 100644 --- a/iam-staff-portal-api/tests/helpers.py +++ b/iam-staff-portal-api/tests/helpers.py @@ -94,6 +94,11 @@ def make_role_row(**overrides) -> types.SimpleNamespace: def make_execute_result(*, all_rows=None, first_row=None, scalar_rows=None): result = MagicMock() + result._is_cursor = False # Mark as non-server-side cursor + # Mock raw.context._is_server_side to prevent server-side cursor error + result.raw = MagicMock() + result.raw.context = MagicMock() + result.raw.context._is_server_side = False scalars = MagicMock() scalars.all.return_value = all_rows if all_rows is not None else [] scalars.first.return_value = first_row diff --git a/iam-staff-portal-api/tests/test_user_access_controller.py b/iam-staff-portal-api/tests/test_user_access_controller.py index b1e7a90..66f38b4 100644 --- a/iam-staff-portal-api/tests/test_user_access_controller.py +++ b/iam-staff-portal-api/tests/test_user_access_controller.py @@ -37,7 +37,7 @@ async def test_get_staff_portal_applications_marks_disabled_without_role(control request = make_request(auth=make_auth(client_roles={"registry-staff-portal": ["Data Editor"]})) with patch( - "iam_staff_portal_api.controllers.user_access_controller.async_sessionmaker", + "iam_staff_portal_api.controllers.user_access_controller.get_async_session_maker", return_value=make_session_factory(session), ): result = await controller.get_staff_portal_applications(request) @@ -75,7 +75,7 @@ async def test_get_application_permissions_for_user_skips_when_role_mappings_mis request = make_request(auth=make_auth(client_roles={"registry-staff-portal": ["Data Editor"]})) with patch( - "iam_staff_portal_api.controllers.user_access_controller.async_sessionmaker", + "iam_staff_portal_api.controllers.user_access_controller.get_async_session_maker", return_value=make_session_factory(session), ): result = await controller.get_application_permissions_for_user(request) @@ -115,7 +115,7 @@ async def test_get_application_permissions_for_user_skips_unknown_and_empty_resu ) with patch( - "iam_staff_portal_api.controllers.user_access_controller.async_sessionmaker", + "iam_staff_portal_api.controllers.user_access_controller.get_async_session_maker", return_value=make_session_factory(session), ): result = await controller.get_application_permissions_for_user(request) @@ -137,7 +137,7 @@ async def test_get_application_permissions_for_user_returns_permissions_for_all_ request = make_request(auth=make_auth(client_roles={"registry-staff-portal": ["Data Editor"]})) with patch( - "iam_staff_portal_api.controllers.user_access_controller.async_sessionmaker", + "iam_staff_portal_api.controllers.user_access_controller.get_async_session_maker", return_value=make_session_factory(session), ): result = await controller.get_application_permissions_for_user(request) @@ -154,7 +154,7 @@ async def test_get_permission_mnemonics_for_role_returns_empty_without_mappings( make_execute_result(all_rows=[]), ) with patch( - "iam_staff_portal_api.controllers.user_access_controller.async_sessionmaker", + "iam_staff_portal_api.controllers.user_access_controller.get_async_session_maker", return_value=make_session_factory(session), ): result = await controller.get_permission_mnemonics_for_role.__wrapped__( @@ -180,7 +180,7 @@ async def test_get_application_permissions_for_user_filters_by_mnemonic(controll ) with patch( - "iam_staff_portal_api.controllers.user_access_controller.async_sessionmaker", + "iam_staff_portal_api.controllers.user_access_controller.get_async_session_maker", return_value=make_session_factory(session), ): result = await controller.get_application_permissions_for_user( @@ -210,7 +210,7 @@ async def test_get_permissions_for_roles_aggregates_unique_permissions(controlle async def test_get_permission_mnemonics_for_role_returns_empty_when_role_missing(controller): session, _ = make_mock_session(make_execute_result(first_row=None)) with patch( - "iam_staff_portal_api.controllers.user_access_controller.async_sessionmaker", + "iam_staff_portal_api.controllers.user_access_controller.get_async_session_maker", return_value=make_session_factory(session), ): result = await controller.get_permission_mnemonics_for_role.__wrapped__( @@ -229,7 +229,7 @@ async def test_get_permission_mnemonics_for_role_resolves_active_permissions(con make_execute_result(all_rows=["register:view", "register:edit"]), ) with patch( - "iam_staff_portal_api.controllers.user_access_controller.async_sessionmaker", + "iam_staff_portal_api.controllers.user_access_controller.get_async_session_maker", return_value=make_session_factory(session), ): result = await controller.get_permission_mnemonics_for_role.__wrapped__( @@ -261,7 +261,7 @@ async def test_register_staff_portal_application_creates_new_application(control with ( patch( - "iam_staff_portal_api.controllers.user_access_controller.async_sessionmaker", + "iam_staff_portal_api.controllers.user_access_controller.get_async_session_maker", return_value=make_session_factory(session), ), patch( @@ -295,7 +295,7 @@ async def test_register_staff_portal_application_updates_existing_application(co with ( patch( - "iam_staff_portal_api.controllers.user_access_controller.async_sessionmaker", + "iam_staff_portal_api.controllers.user_access_controller.get_async_session_maker", return_value=make_session_factory(session), ), patch(