-
Notifications
You must be signed in to change notification settings - Fork 14
feat: Add OpaClient to wrap auth checks #1541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: depends-user
Are you sure you want to change the base?
Changes from all commits
0e68df4
a7ae4a4
a0def5f
9a38128
5e8af18
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import logging | ||
| from collections.abc import Mapping | ||
| from contextlib import AbstractAsyncContextManager, aclosing, nullcontext | ||
| from typing import Any, Self | ||
|
|
||
| from aiohttp import ClientSession | ||
|
|
||
| from blueapi.config import OpaConfig | ||
|
|
||
| LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class OpaClient: | ||
| def __init__(self, instrument: str, config: OpaConfig): | ||
| LOGGER.info("Creating OpaClient for %s with config %s", instrument, config) | ||
| self._instrument = instrument | ||
| self._config = config | ||
| self._session = ClientSession(base_url=config.root.encoded_string()) | ||
| self._audience = config.audience | ||
|
|
||
| async def aclose(self): | ||
| LOGGER.info("Closing OPA session") | ||
| await self._session.close() | ||
|
|
||
| async def _call_opa(self, endpoint: str, data: Mapping[str, Any]) -> bool: | ||
| resp = await self._session.post( | ||
| endpoint, | ||
| json={ | ||
| "input": { | ||
| "beamline": self._instrument, | ||
| "audience": self._audience, | ||
| **data, | ||
| } | ||
| }, | ||
| ) | ||
| return (await resp.json())["result"] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Will be good to TypeAdapter the result to bool because of python Something like this I think we should as put this in or just Exception
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What does the TypeAdapter protect against? OPA returning For the exception handling, does wrapping the exception here add much beyond letting the original exception be raised? |
||
|
|
||
| @classmethod | ||
| def for_config( | ||
| cls, instrument: str | None, config: OpaConfig | None | ||
| ) -> AbstractAsyncContextManager[Self | None]: | ||
| if config: | ||
| if not instrument: | ||
| raise ValueError("Instrument name is required for OPA client") | ||
| return aclosing(cls(instrument, config)) | ||
| LOGGER.info("No OPA config provided - not creating OpaClient") | ||
| return nullcontext() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| from unittest.mock import AsyncMock, MagicMock, patch | ||
|
|
||
| import pytest | ||
| from pydantic import HttpUrl | ||
|
|
||
| from blueapi.config import OpaConfig | ||
| from blueapi.service.authorization import ( | ||
| OpaClient, | ||
| ) | ||
|
|
||
| # Reusable client patch decorator | ||
| patch_client_session = patch( | ||
| "blueapi.service.authorization.ClientSession", | ||
| name="mock_client_session", | ||
| spec=True, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture(scope="module") | ||
| def opa_config() -> OpaConfig: | ||
| return OpaConfig( | ||
| root=HttpUrl("http://auth.example.com"), | ||
| ) | ||
|
|
||
|
|
||
| @patch_client_session | ||
| async def test_session_closed(session: MagicMock, opa_config: OpaConfig): | ||
| async with OpaClient.for_config("p45", opa_config): | ||
| pass | ||
| session().close.assert_called_once() | ||
|
|
||
|
|
||
| @patch_client_session | ||
| async def test_opa_client_for_config(session: MagicMock, opa_config: OpaConfig): | ||
| async with OpaClient.for_config("p45", opa_config) as opa: | ||
| assert opa is not None | ||
| session.assert_called_once_with(base_url="http://auth.example.com/") | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("instrument", [None, "p99"]) | ||
| async def test_opa_client_without_config(instrument: str | None): | ||
| async with OpaClient.for_config(instrument, None) as opa: | ||
| assert opa is None | ||
|
|
||
|
|
||
| async def test_opa_fails_without_instrument(opa_config: OpaConfig): | ||
| with pytest.raises(ValueError, match="Instrument name is required"): | ||
| OpaClient.for_config(None, opa_config) | ||
|
|
||
|
|
||
| @patch_client_session | ||
| async def test_opa_adds_input_fields(session: MagicMock, opa_config: OpaConfig): | ||
| session.return_value.post = AsyncMock() | ||
| async with OpaClient.for_config("p45", opa_config) as opa: | ||
| assert opa is not None | ||
| await opa._call_opa("foo/bar", data={"foo": "bar"}) | ||
|
|
||
| session.assert_called_once() | ||
| session().post.assert_called_once_with( | ||
| "foo/bar", | ||
| json={"input": {"beamline": "p45", "audience": "account", "foo": "bar"}}, | ||
| ) |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.