Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## 0.3.0

- Answer `401` to a `POST /aidbox` that does not carry the app's `APP_ID:APP_SECRET` credentials
- Drop Python 3.9 and 3.10, both end of life


Expand Down
13 changes: 13 additions & 0 deletions aidbox_python_sdk/handlers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncio
import base64
import logging
import secrets
from typing import Any

from aiohttp import web
Expand All @@ -11,6 +13,13 @@
routes = web.RouteTableDef()


def is_from_aidbox(request: web.Request) -> bool:
"""Aidbox calls an http-rpc endpoint with the secret the app registered it under."""
settings = request.app[ak.settings]
expected = b"Basic " + base64.b64encode(f"{settings.APP_ID}:{settings.APP_SECRET}".encode())
return secrets.compare_digest(request.headers.get("Authorization", "").encode(), expected)


async def subscription(request: web.Request, data: dict):
logger.debug("Subscription handler: %s", data["handler"])
if "handler" not in data or "event" not in data:
Expand Down Expand Up @@ -59,6 +68,10 @@ async def operation(request: web.Request, data: dict[str, Any]):
@routes.post("/aidbox")
async def dispatch(request):
logger.debug("Dispatch new request %s %s", request.method, request.url)
if not is_from_aidbox(request):
logger.error("Dispatch request without the app's credentials")
raise web.HTTPUnauthorized()

data = await request.json()
if "type" in data and data["type"] in TYPES:
logger.debug("Dispatch to `%s` handler", data["type"])
Expand Down
13 changes: 13 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,16 @@ async def observation_custom_op(operation, request):
)
async def operation_outcome_test_op(operation, request):
raise OperationOutcome(reason="test reason")


@sdk.operation(
["POST"],
["$dispatch-test"],
)
async def dispatch_test_op(operation, request):
return web.json_response({"status": "ok"})


@sdk.subscription("Location")
async def dispatch_test_sub(event, request):
pass
70 changes: 70 additions & 0 deletions tests/test_sdk.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import base64
import logging
from unittest import mock

Expand All @@ -7,6 +8,7 @@
from fhirpy.base.exceptions import OperationOutcome

import main
from aidbox_python_sdk import app_keys as ak
from aidbox_python_sdk.db import DBProxy


Expand Down Expand Up @@ -200,3 +202,71 @@ async def test_operation_outcome_test_op(aidbox_client):
with pytest.raises(OperationOutcome) as exc:
await aidbox_client.execute("/$operation-outcome-test")
assert exc.value.resource.get("issue")[0].get("diagnostics") == "test reason"


DISPATCH_TEST_OP = {
"type": "operation",
"operation": {"id": "POST.main.dispatch_test_op.ddispatch-test"},
"request": {},
}
DISPATCH_TEST_EVENT = {"type": "subscription", "handler": "dispatch_test_sub", "event": {}}
ANOTHER_APP_AUTHORIZATION = f"Basic {base64.b64encode(b'app-test:another-secret').decode()}"


@pytest.fixture
def aidbox_authorization(app):
settings = app[ak.settings]
credentials = f"{settings.APP_ID}:{settings.APP_SECRET}".encode()
return f"Basic {base64.b64encode(credentials).decode()}"


@pytest.mark.asyncio
async def test_dispatch_refuses_an_operation_without_credentials(client):
resp = await client.post("/aidbox", json=DISPATCH_TEST_OP)

assert resp.status == 401


@pytest.mark.asyncio
async def test_dispatch_refuses_an_operation_with_another_app_secret(client):
resp = await client.post(
"/aidbox", json=DISPATCH_TEST_OP, headers={"Authorization": ANOTHER_APP_AUTHORIZATION}
)

assert resp.status == 401


@pytest.mark.asyncio
async def test_dispatch_invokes_an_operation_for_aidbox(client, aidbox_authorization):
resp = await client.post(
"/aidbox", json=DISPATCH_TEST_OP, headers={"Authorization": aidbox_authorization}
)

assert resp.status == 200
# The fallback branch answers 200 too, so only the operation's own body proves it ran.
assert await resp.json() == {"status": "ok"}


@pytest.mark.asyncio
async def test_dispatch_refuses_a_subscription_without_credentials(client):
resp = await client.post("/aidbox", json=DISPATCH_TEST_EVENT)

assert resp.status == 401


@pytest.mark.asyncio
async def test_dispatch_refuses_a_subscription_with_another_app_secret(client):
resp = await client.post(
"/aidbox", json=DISPATCH_TEST_EVENT, headers={"Authorization": ANOTHER_APP_AUTHORIZATION}
)

assert resp.status == 401


@pytest.mark.asyncio
async def test_dispatch_triggers_a_subscription_for_aidbox(client, aidbox_authorization):
resp = await client.post(
"/aidbox", json=DISPATCH_TEST_EVENT, headers={"Authorization": aidbox_authorization}
)

assert resp.status == 200
Loading