diff --git a/gso/__init__.py b/gso/__init__.py index c0dd125738c7eec85455683a17e4d24757eefccd..c5dbc6b0f14c3e9ecc89e8615a6bae66ae384aa4 100644 --- a/gso/__init__.py +++ b/gso/__init__.py @@ -10,8 +10,7 @@ import gso.products import gso.workflows # noqa: F401 from gso.api import router as api_router from gso.auth.oidc import oidc_instance -from gso.auth.opa import opa_instance -from gso.middlewares import ModifyProcessEndpointResponse +from gso.auth.opa import graphql_opa_instance, opa_instance from gso.graphql_api.types import GSO_SCALAR_OVERRIDES SCALAR_OVERRIDES.update(GSO_SCALAR_OVERRIDES) @@ -22,6 +21,7 @@ def init_gso_app() -> OrchestratorCore: app = OrchestratorCore(base_settings=app_settings) app.register_authentication(oidc_instance) app.register_authorization(opa_instance) + app.register_graphql_authorization(graphql_opa_instance) app.register_graphql() app.include_router(api_router, prefix="/api") return app diff --git a/gso/api/v1/imports.py b/gso/api/v1/imports.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/gso/api/v1/processes.py b/gso/api/v1/processes.py index 7664486a7bb8f9863505e378c59125e3c94f1ac0..f4977ed463835b8cb5f8fc54edc32f3675cabb13 100644 --- a/gso/api/v1/processes.py +++ b/gso/api/v1/processes.py @@ -8,7 +8,6 @@ from orchestrator.db import ProcessStepTable from orchestrator.schemas.base import OrchestratorBaseModel from orchestrator.security import authorize - router = APIRouter(prefix="/processes", tags=["Processes"], dependencies=[Depends(authorize)]) diff --git a/gso/auth/oidc.py b/gso/auth/oidc.py index a1a9547c17966a755c3fe6cc840058c1fd4be09f..d9acb24534d894242cf0946d5d57087ee014d1bf 100644 --- a/gso/auth/oidc.py +++ b/gso/auth/oidc.py @@ -1,17 +1,11 @@ -"""OpenID Connect and Open Policy Agent Integration for GSO Application. - -This module provides helper functions and classes for handling OpenID Connect (OIDC) and -Open Policy Agent (OPA) related functionalities within the GSO application. It includes -implementations for OIDC-based user authentication and user information modeling. Additionally, -it facilitates making authorization decisions based on policies defined in OPA. Key components -comprise OIDCUser, OIDCUserModel, OPAResult, and opa_decision. These elements integrate with -FastAPI to ensure secure API development. -""" +"""Module contains the OIDC Authentication class.""" import re +from collections.abc import Callable from functools import wraps from http import HTTPStatus from json import JSONDecodeError +from typing import Any from fastapi.exceptions import HTTPException from fastapi.requests import Request @@ -28,14 +22,19 @@ _CALLBACK_STEP_API_URL_PATTERN = re.compile( ) +def _is_client_credentials_token(intercepted_token: dict) -> bool: + return "sub" not in intercepted_token + def _is_callback_step_endpoint(request: Request) -> bool: """Check if the request is a callback step API call.""" return re.match(_CALLBACK_STEP_API_URL_PATTERN, request.url.path) is not None -def ensure_openid_config_loaded(func): +def ensure_openid_config_loaded(func: Callable) -> Callable: + """Ensure that the openid_config is loaded before calling the function.""" + @wraps(func) - async def wrapper(self, async_request: AsyncClient, *args, **kwargs): + async def wrapper(self: OIDCAuth, async_request: AsyncClient, *args: Any, **kwargs: Any) -> dict: await self.check_openid_config(async_request) return await func(self, async_request, *args, **kwargs) @@ -49,15 +48,9 @@ class OIDCAuthentication(OIDCAuth): 1. Validate the Credentials at :term: `AAI` proxy by calling the UserInfo endpoint """ - _instance = None - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super(OIDCAuthentication, cls).__new__(cls) - return cls._instance - @staticmethod async def is_bypassable_request(request: Request) -> bool: + """Check if the request is a callback step API call.""" return _is_callback_step_endpoint(request=request) @ensure_openid_config_loaded @@ -69,7 +62,12 @@ class OIDCAuthentication(OIDCAuth): :return: OIDCUserModel: OIDC user model from openid server """ - await self.introspect_token(async_request, token) + intercepted_token = await self.introspect_token(async_request, token) + client_id = intercepted_token.get("client_id") + if _is_client_credentials_token(intercepted_token): + return OIDCUserModel( + client_id=client_id + ) response = await async_request.post( self.openid_config.userinfo_endpoint, @@ -97,6 +95,8 @@ class OIDCAuthentication(OIDCAuth): ) raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=response.text) + data["client_id"] = client_id + return OIDCUserModel(data) @ensure_openid_config_loaded diff --git a/gso/auth/oidc_policy_helper.py b/gso/auth/oidc_policy_helper.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/gso/auth/opa.py b/gso/auth/opa.py index 88c8edfb9a795de1ee535bd364ce59f47b90fa91..9d801a8b958b7283f23aca3ac12a530451e85d62 100644 --- a/gso/auth/opa.py +++ b/gso/auth/opa.py @@ -1,41 +1,49 @@ +"""Module contains the OPA authorization class that is used to get decisions from the OPA server.""" + from http import HTTPStatus from fastapi.exceptions import HTTPException -from fastapi.params import Depends from httpx import AsyncClient, NetworkError -from oauth2_lib.fastapi import OIDCUserModel, OPAAuthorization, OPAResult +from oauth2_lib.fastapi import GraphQLOPAAuthorization, OPAAuthorization, OPAResult from oauth2_lib.settings import oauth2lib_settings -from starlette.requests import Request from structlog import get_logger -from gso.auth.oidc import oidc_instance - logger = get_logger(__name__) -class OPAAuthorization(OPAAuthorization): - _instance = None +async def _get_decision(opa_url: str, async_request: AsyncClient, opa_input: dict) -> OPAResult: + logger.debug("Posting input json to Policy agent", opa_url=opa_url, input=opa_input) + try: + result = await async_request.post(opa_url, json=opa_input) + except (NetworkError, TypeError) as exc: + logger.debug("Could not get decision from policy agent", error=str(exc)) + raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Policy agent is unavailable") from exc + + json_result = result.json() + logger.debug("Received decision from policy agent", decision=json_result) - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super(OPAAuthorization, cls).__new__(cls) - return cls._instance + return OPAResult(decision_id=json_result["decision_id"], result=json_result["result"]["allow"]) + + +class OPAAuthZ(OPAAuthorization): + """Applies OPA decisions to HTTP requests for authorization.""" async def get_decision(self, async_request: AsyncClient, opa_input: dict) -> OPAResult: - logger.debug("Posting input json to Policy agent", opa_url=self.opa_url, input=opa_input) - try: - result = await async_request.post(self.opa_url, json=opa_input) - except (NetworkError, TypeError) as exc: - logger.debug("Could not get decision from policy agent", error=str(exc)) - raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Policy agent is unavailable") + """Get the decision from the OPA server.""" + return await _get_decision(self.opa_url, async_request, opa_input) - json_result = result.json() - logger.debug("Received decision from policy agent", decision=json_result) - return OPAResult(decision_id=json_result["decision_id"], result=json_result["result"]["allow"]) +class GraphQLOPAAuthZ(GraphQLOPAAuthorization): + """Specializes OPA authorization for GraphQL operations.""" -opa_instance = OPAAuthorization( + async def get_decision(self, async_request: AsyncClient, opa_input: dict) -> OPAResult: + """Get the decision from the OPA server.""" + return await _get_decision(self.opa_url, async_request, opa_input) + + +opa_instance = OPAAuthZ( + opa_url=oauth2lib_settings.OPA_URL, +) +graphql_opa_instance = GraphQLOPAAuthZ( opa_url=oauth2lib_settings.OPA_URL, ) - -# TODO - Think about Inventoryo-provider since it is not defined in the code but is used in the old branch \ No newline at end of file diff --git a/gso/monkeypatches.py b/gso/monkeypatches.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/gso/services/infoblox.py b/gso/services/infoblox.py index d340ce979f14a8c4d767f0f0e9ccf10331174387..9fc668cd11f6df1ef9d2f796f9242ef3a7ea1309 100644 --- a/gso/services/infoblox.py +++ b/gso/services/infoblox.py @@ -37,7 +37,7 @@ def _setup_connection() -> tuple[connector.Connector, IPAMParams]: return connector.Connector(options), oss -def _allocate_network( # noqa: PLR0917 +def _allocate_network( conn: connector.Connector, dns_view: str, network_view: str, diff --git a/gso/settings.py b/gso/settings.py index fcacd37691ec358c48db59313b7f97332dd4941d..79182702b7eab55c06dca563a90df5c152606bc6 100644 --- a/gso/settings.py +++ b/gso/settings.py @@ -173,6 +173,7 @@ class SharepointParams(BaseSettings): class AuthParams(BaseSettings): """Parameters for the authentication service.""" + opa_url: str diff --git a/gso/workflows/__init__.py b/gso/workflows/__init__.py index b6072c1c6bae54d2d6af93abdba533655902957c..e75660820d72179e03948a402cc9f6a1a636a83a 100644 --- a/gso/workflows/__init__.py +++ b/gso/workflows/__init__.py @@ -10,17 +10,19 @@ ALL_ALIVE_STATES: list[str] = [ SubscriptionLifecycle.ACTIVE, ] -WF_USABLE_MAP.update({ - "redeploy_base_config": [SubscriptionLifecycle.PROVISIONING, SubscriptionLifecycle.ACTIVE], - "update_ibgp_mesh": [SubscriptionLifecycle.PROVISIONING, SubscriptionLifecycle.ACTIVE], - "activate_router": [SubscriptionLifecycle.PROVISIONING], - "deploy_twamp": [SubscriptionLifecycle.PROVISIONING, SubscriptionLifecycle.ACTIVE], - "modify_trunk_interface": [SubscriptionLifecycle.PROVISIONING, SubscriptionLifecycle.ACTIVE], - "activate_iptrunk": [SubscriptionLifecycle.PROVISIONING], - "terminate_site": ALL_ALIVE_STATES, - "terminate_router": ALL_ALIVE_STATES, - "terminate_iptrunk": ALL_ALIVE_STATES, -}) +WF_USABLE_MAP.update( + { + "redeploy_base_config": [SubscriptionLifecycle.PROVISIONING, SubscriptionLifecycle.ACTIVE], + "update_ibgp_mesh": [SubscriptionLifecycle.PROVISIONING, SubscriptionLifecycle.ACTIVE], + "activate_router": [SubscriptionLifecycle.PROVISIONING], + "deploy_twamp": [SubscriptionLifecycle.PROVISIONING, SubscriptionLifecycle.ACTIVE], + "modify_trunk_interface": [SubscriptionLifecycle.PROVISIONING, SubscriptionLifecycle.ACTIVE], + "activate_iptrunk": [SubscriptionLifecycle.PROVISIONING], + "terminate_site": ALL_ALIVE_STATES, + "terminate_router": ALL_ALIVE_STATES, + "terminate_iptrunk": ALL_ALIVE_STATES, + } +) # IP trunk workflows LazyWorkflowInstance("gso.workflows.iptrunk.activate_iptrunk", "activate_iptrunk") diff --git a/gso/workflows/iptrunk/create_imported_iptrunk.py b/gso/workflows/iptrunk/create_imported_iptrunk.py index 9b0e6b87a8a095073875721e79d72c9e03baa66d..0e5a2564039ef3ff1893cf054fed4e62534fdb8f 100644 --- a/gso/workflows/iptrunk/create_imported_iptrunk.py +++ b/gso/workflows/iptrunk/create_imported_iptrunk.py @@ -123,10 +123,12 @@ def initialize_subscription( subscription.iptrunk.iptrunk_sides[1].iptrunk_side_ae_members.append( IptrunkInterfaceBlockInactive.new(subscription_id=uuid4(), **member), ) - side_names = sorted([ - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_site.site_name, - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name, - ]) + side_names = sorted( + [ + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_site.site_name, + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name, + ] + ) subscription.description = f"IP trunk {side_names[0]} {side_names[1]}, geant_s_sid:{geant_s_sid}" return {"subscription": subscription} diff --git a/gso/workflows/iptrunk/modify_trunk_interface.py b/gso/workflows/iptrunk/modify_trunk_interface.py index d64331720fcb85cccc829f58d6d3d8b437e55b77..4c7efae913b39938e6f404f71327ab728ff22161 100644 --- a/gso/workflows/iptrunk/modify_trunk_interface.py +++ b/gso/workflows/iptrunk/modify_trunk_interface.py @@ -229,10 +229,12 @@ def modify_iptrunk_subscription( IptrunkInterfaceBlock.new(subscription_id=uuid4(), **member), ) - side_names = sorted([ - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_site.site_name, - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name, - ]) + side_names = sorted( + [ + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_site.site_name, + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name, + ] + ) subscription.description = f"IP trunk {side_names[0]} {side_names[1]}, geant_s_sid:{geant_s_sid}" return { diff --git a/requirements.txt b/requirements.txt index a430ae38c707028526b4e36a0735110ec0b44157..9c8e28c3a21f6db906c1cccb4c8dc792fdb846bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -orchestrator-core==2.1.2 +orchestrator-core==2.3.0rc3 requests==2.31.0 infoblox-client~=0.6.0 pycountry==23.12.11 diff --git a/setup.py b/setup.py index 6f52af7f665438ed81928fa4b245d5ec220166b6..169f80789183f482c50491e3bc5f1a391a211e75 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( url="https://gitlab.software.geant.org/goat/gap/geant-service-orchestrator", packages=find_packages(), install_requires=[ - "orchestrator-core==2.2.1", + "orchestrator-core==2.3.0rc3", "requests==2.31.0", "infoblox-client~=0.6.0", "pycountry==23.12.11", diff --git a/test/auth/test_oidc_policy_helper.py b/test/auth/test_oidc_policy_helper.py index 4152e1d016b1599de51d749f825479914d78e2bd..9aa313b43035b55a4daba9d85b28c8eb2b607b96 100644 --- a/test/auth/test_oidc_policy_helper.py +++ b/test/auth/test_oidc_policy_helper.py @@ -6,8 +6,14 @@ from fastapi import HTTPException, Request from httpx import AsyncClient, NetworkError, Response from gso.auth.oidc import ( + OIDCAuthentication, OIDCConfig, - OIDCAuthentication, OIDCUserModel, OPAResult, opa_decision, _get_decision, _evaluate_decision, _is_callback_step_endpoint, + OIDCUserModel, + OPAResult, + _evaluate_decision, + _get_decision, + _is_callback_step_endpoint, + opa_decision, ) from gso.auth.settings import oauth2lib_settings @@ -275,7 +281,7 @@ async def test_oidc_user_call_no_token(oidc_user, mock_request): mock_post.return_value = MagicMock(status_code=200, json=lambda: {"active": False}) mock_get.return_value = MagicMock(status_code=200, json=dict) - result = await oidc_user.__call__(mock_request) # noqa: PLC2801 + result = await oidc_user.__call__(mock_request) assert result is None @@ -288,7 +294,7 @@ async def test_oidc_user_call_token_from_request(oidc_user, mock_request, mock_a oidc_user.introspect_token = AsyncMock(return_value={"active": True, "sub": "123"}) oidc_user.userinfo = AsyncMock(return_value=OIDCUserModel({"sub": "123", "name": "John Doe"})) - result = await oidc_user.__call__(mock_request) # noqa: PLC2801 + result = await oidc_user.__call__(mock_request) assert isinstance(result, OIDCUserModel) assert result["sub"] == "123" diff --git a/test/workflows/iptrunk/test_create_iptrunk.py b/test/workflows/iptrunk/test_create_iptrunk.py index de15a7124e0311e4a3127ccc10477fd39a4a02b7..13b7f69b5f18c42314c7bcb196e05a1b25a18643 100644 --- a/test/workflows/iptrunk/test_create_iptrunk.py +++ b/test/workflows/iptrunk/test_create_iptrunk.py @@ -137,10 +137,12 @@ def test_successful_iptrunk_creation_with_standard_lso_result( subscription_id = state["subscription_id"] subscription = Iptrunk.from_subscription(subscription_id) - sorted_sides = sorted([ - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_site.site_name, - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name, - ]) + sorted_sides = sorted( + [ + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_site.site_name, + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name, + ] + ) assert subscription.status == "provisioning" assert subscription.description == ( f"IP trunk {sorted_sides[0]} {sorted_sides[1]}, geant_s_sid:{input_form_wizard_data[0]["geant_s_sid"]}" diff --git a/test/workflows/iptrunk/test_modify_trunk_interface.py b/test/workflows/iptrunk/test_modify_trunk_interface.py index 2a95cc90fc6428dd19c7e9a2b0a0f77a58ef3a2b..98d41e8fc940e8064b9b2c25d8ed717604929094 100644 --- a/test/workflows/iptrunk/test_modify_trunk_interface.py +++ b/test/workflows/iptrunk/test_modify_trunk_interface.py @@ -158,10 +158,12 @@ def test_iptrunk_modify_trunk_interface_success( assert mocked_detach_interfaces_from_lag.call_count == num_lag_ifaces # 1 time per nokia side # Assert all subscription properties have been updated correctly - side_names = sorted([ - subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_site.site_name, - subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name, - ]) + side_names = sorted( + [ + subscription.iptrunk.iptrunk_sides[0].iptrunk_side_node.router_site.site_name, + subscription.iptrunk.iptrunk_sides[1].iptrunk_side_node.router_site.site_name, + ] + ) assert subscription.description == f"IP trunk {side_names[0]} {side_names[1]}, geant_s_sid:{new_sid}" assert subscription.iptrunk.geant_s_sid == input_form_iptrunk_data[1]["geant_s_sid"] assert subscription.iptrunk.iptrunk_description == input_form_iptrunk_data[1]["iptrunk_description"]