From 13141f8cb4ed76ebc26ab816abc5bc4477ddfe07 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Fri, 27 Jun 2025 20:28:13 +0300 Subject: [PATCH 1/7] chore: bump version --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- src/rasenmaeher_api/__init__.py | 2 +- tests/test_rasenmaeher_api.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1437af4..46f185e 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.7.0 +current_version = 1.8.0 commit = False tag = False diff --git a/pyproject.toml b/pyproject.toml index 483d704..fae0c6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "rasenmaeher_api" -version = "1.7.0" +version = "1.8.0" description = "python-rasenmaeher-api" authors = [ "Aciid <703382+Aciid@users.noreply.github.com>", diff --git a/src/rasenmaeher_api/__init__.py b/src/rasenmaeher_api/__init__.py index 5c82044..580c021 100644 --- a/src/rasenmaeher_api/__init__.py +++ b/src/rasenmaeher_api/__init__.py @@ -1,3 +1,3 @@ """python-rasenmaeher-api""" -__version__ = "1.7.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly +__version__ = "1.8.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly diff --git a/tests/test_rasenmaeher_api.py b/tests/test_rasenmaeher_api.py index 33cc277..1ccc260 100644 --- a/tests/test_rasenmaeher_api.py +++ b/tests/test_rasenmaeher_api.py @@ -16,7 +16,7 @@ def test_version() -> None: """Make sure version matches expected""" - assert __version__ == "1.7.0" + assert __version__ == "1.8.0" @pytest.mark.asyncio(loop_scope="session") From 20457c87b4bb28bb06d845c26e37fdb638dd49aa Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Sat, 28 Jun 2025 02:35:09 +0300 Subject: [PATCH 2/7] feat: allow product to request interop privileges --- src/rasenmaeher_api/web/api/product/schema.py | 21 +++++++++++++ src/rasenmaeher_api/web/api/product/views.py | 30 ++++++++++++++++++- tests/ptfpapi/fprun.py | 12 ++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/rasenmaeher_api/web/api/product/schema.py b/src/rasenmaeher_api/web/api/product/schema.py index 15b857d..54ae09b 100644 --- a/src/rasenmaeher_api/web/api/product/schema.py +++ b/src/rasenmaeher_api/web/api/product/schema.py @@ -95,3 +95,24 @@ class Config: # pylint: disable=too-few-public-methods }, ] } + + +# FIXME: Move to libpvarki +class ProductAddRequest(BaseModel): # pylint: disable=too-few-public-methods + """Request to add product interoperability.""" + + certcn: str = Field(description="CN of the certificate") + x509cert: str = Field(description="Certificate encoded with CFSSL conventions (newlines escaped)") + + class Config: # pylint: disable=too-few-public-methods + """Example values for schema""" + + extra = Extra.forbid + schema_extra = { + "examples": [ + { + "certcn": "product.deployment.tld", + "x509cert": "-----BEGIN CERTIFICATE-----\\nMIIEwjCC...\\n-----END CERTIFICATE-----\\n", + }, + ], + } diff --git a/src/rasenmaeher_api/web/api/product/views.py b/src/rasenmaeher_api/web/api/product/views.py index de0472c..f56ffe3 100644 --- a/src/rasenmaeher_api/web/api/product/views.py +++ b/src/rasenmaeher_api/web/api/product/views.py @@ -1,5 +1,6 @@ """Product registration API views.""" +from typing import cast import logging from fastapi import APIRouter, Depends, HTTPException, Request @@ -11,7 +12,7 @@ from OpenSSL.crypto import load_certificate_request, FILETYPE_PEM # FIXME: use cryptography instead of pyOpenSSL -from .schema import CertificatesResponse, CertificatesRequest, RevokeRequest, KCClientToken +from .schema import CertificatesResponse, CertificatesRequest, RevokeRequest, KCClientToken, ProductAddRequest from ....db.nonces import SeenToken from ....db.errors import NotFound from ....cfssl.public import get_ca, get_bundle @@ -19,6 +20,7 @@ from ....cfssl.base import CFSSLError from ....rmsettings import RMSettings from ....kchelpers import KCClient +from ....productapihelpers import post_to_product router = APIRouter() @@ -138,3 +140,29 @@ async def get_kc_token( raise HTTPException(403, detail="KC is not enabled") data = await KCClient.singleton().client_access_token() return KCClientToken.parse_obj(data) + + +@router.post("/interop/{tgtproduct}", dependencies=[Depends(MTLSHeader(auto_error=True))]) +async def add_interop( + srcproduct: ProductAddRequest, + tgtproduct: str, + request: Request, +) -> OperationResultResponse: + """Product needs interop privileges with another""" + payload = request.state.mtlsdn + if payload.get("CN") not in RMSettings.singleton().valid_product_cns: + raise HTTPException(status_code=403) + + # TODO: Verify that srcproduct certcn and actual cert contents match + + manifest = RMSettings.singleton().kraftwerk_manifest_dict + if "products" not in manifest: + LOGGER.error("Manifest does not have products key") + raise HTTPException(status_code=500, detail="Manifest does not have products key") + if not tgtproduct in manifest["products"]: + raise HTTPException(status_code=404, detail=f"Unknown product {tgtproduct}") + resp = await post_to_product(tgtproduct, "/api/v1/interop/add", srcproduct.dict(), OperationResultResponse) + if resp is None: + return OperationResultResponse(success=False, error="post_to_product returned None") + resp = cast(OperationResultResponse, resp) + return resp diff --git a/tests/ptfpapi/fprun.py b/tests/ptfpapi/fprun.py index 25922ee..b0332bb 100644 --- a/tests/ptfpapi/fprun.py +++ b/tests/ptfpapi/fprun.py @@ -19,6 +19,10 @@ UserCRUDRequest, ) + +from rasenmaeher_api.web.api.product.schema import ProductAddRequest + + LOGGER = logging.getLogger(__name__) @@ -128,6 +132,13 @@ async def handle_admin_fragment(request: web.Request) -> web.Response: return web.json_response(resp.dict()) +async def handle_interop_add(request: web.Request) -> web.Response: + """Respond to additions""" + _req = ProductAddRequest.parse_raw(await request.text()) + resp = OperationResultResponse(success=True, extra="Nothing was actually done, this is a fake endpoint for testing") + return web.json_response(resp.dict()) + + def main() -> int: """Main entrypoint, return exit code""" LOGGER.debug("Called") @@ -146,6 +157,7 @@ def main() -> int: [ web.get("/", handle_get_hello), web.get("/{name}", handle_get_hello), + web.post("/api/v1/interop/add", handle_user_crud), web.post("/api/v1/users/created", handle_user_crud), web.post("/api/v1/users/revoked", handle_user_crud), web.post("/api/v1/users/promoted", handle_user_crud), From adb757117bb9ea6fa5b74a8715071bfe15048824 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Sat, 28 Jun 2025 02:35:09 +0300 Subject: [PATCH 3/7] feat: allow product to request interop privileges --- tests/ptfpapi/fprun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ptfpapi/fprun.py b/tests/ptfpapi/fprun.py index b0332bb..5884590 100644 --- a/tests/ptfpapi/fprun.py +++ b/tests/ptfpapi/fprun.py @@ -157,7 +157,7 @@ def main() -> int: [ web.get("/", handle_get_hello), web.get("/{name}", handle_get_hello), - web.post("/api/v1/interop/add", handle_user_crud), + web.post("/api/v1/interop/add", handle_interop_add), web.post("/api/v1/users/created", handle_user_crud), web.post("/api/v1/users/revoked", handle_user_crud), web.post("/api/v1/users/promoted", handle_user_crud), From bb25494b8116139d695774a7ecf1925a2543b988 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Sat, 28 Jun 2025 12:11:19 +0300 Subject: [PATCH 4/7] fix: we cannot import from rasenmaeher_api inside the test container --- tests/ptfpapi/fprun.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/ptfpapi/fprun.py b/tests/ptfpapi/fprun.py index 5884590..7446730 100644 --- a/tests/ptfpapi/fprun.py +++ b/tests/ptfpapi/fprun.py @@ -18,9 +18,28 @@ UserInstructionFragment, UserCRUDRequest, ) +from pydantic import BaseModel, Field, Extra -from rasenmaeher_api.web.api.product.schema import ProductAddRequest +# FIXME: Move to libpvarki +class ProductAddRequest(BaseModel): # pylint: disable=too-few-public-methods + """Request to add product interoperability.""" + + certcn: str = Field(description="CN of the certificate") + x509cert: str = Field(description="Certificate encoded with CFSSL conventions (newlines escaped)") + + class Config: # pylint: disable=too-few-public-methods + """Example values for schema""" + + extra = Extra.forbid + schema_extra = { + "examples": [ + { + "certcn": "product.deployment.tld", + "x509cert": "-----BEGIN CERTIFICATE-----\\nMIIEwjCC...\\n-----END CERTIFICATE-----\\n", + }, + ], + } LOGGER = logging.getLogger(__name__) From 696acef2704723ab5cb71fb1be0cd99b32338e07 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Sat, 28 Jun 2025 22:30:48 +0300 Subject: [PATCH 5/7] fix: ignore duplicate code until it is in common library --- tests/ptfpapi/fprun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ptfpapi/fprun.py b/tests/ptfpapi/fprun.py index 7446730..6529da3 100644 --- a/tests/ptfpapi/fprun.py +++ b/tests/ptfpapi/fprun.py @@ -22,7 +22,7 @@ # FIXME: Move to libpvarki -class ProductAddRequest(BaseModel): # pylint: disable=too-few-public-methods +class ProductAddRequest(BaseModel): # pylint: disable=too-few-public-methods,R0801 """Request to add product interoperability.""" certcn: str = Field(description="CN of the certificate") From 346d215c42c07bbdeeb83b07672fa3593ec8f3c4 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Sun, 29 Jun 2025 03:27:55 +0300 Subject: [PATCH 6/7] fix: add test for interop request --- tests/conftest.py | 5 ++++ tests/test_interop.py | 55 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/test_interop.py diff --git a/tests/conftest.py b/tests/conftest.py index 30397d5..0a8f555 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -110,6 +110,11 @@ def session_env_config( # pylint: disable=R0915,R0914 "uri": "https://nonexistent.localmaeher.dev.pvarki.fi:844/", # Not actually there "certcn": "nonexistent.localmaeher.dev.pvarki.fi", }, + "interoptest": { + "api": "https://localhost:4657/", + "uri": "https://interoptest.localmaeher.dev.pvarki.fi:844/", # Not actually there + "certcn": "interoptest.localmaeher.dev.pvarki.fi", + }, }, } ) diff --git a/tests/test_interop.py b/tests/test_interop.py new file mode 100644 index 0000000..4c065e4 --- /dev/null +++ b/tests/test_interop.py @@ -0,0 +1,55 @@ +"""Test the interop route""" + +import logging + +import pytest +from async_asgi_testclient import TestClient + +from rasenmaeher_api.web.api.product.schema import ProductAddRequest + +LOGGER = logging.getLogger(__name__) + + +@pytest.mark.asyncio +async def test_valid_products(ginosession: None, unauth_client: TestClient) -> None: + """Test requesting interop with product 'fake'""" + _ = ginosession + client = unauth_client + client.headers.update({"X-ClientCert-DN": "CN=interoptest.localmaeher.dev.pvarki.fi,O=N/A"}) + req = ProductAddRequest( + certcn="interoptest.localmaeher.dev.pvarki.fi", + x509cert="-----BEGIN CERTIFICATE-----\\nMIIEwjCC...\\n-----END CERTIFICATE-----\\n", + ) + payload = req.dict() + resp = await client.post("/api/v1/product/interop/fake", json=payload) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_invalid_requester(ginosession: None, unauth_client: TestClient) -> None: + """Test requesting interop with product 'fake' with product that is not valid""" + _ = ginosession + client = unauth_client + client.headers.update({"X-ClientCert-DN": "CN=callsigndude,O=N/A"}) + req = ProductAddRequest( + certcn="callsigndude", + x509cert="-----BEGIN CERTIFICATE-----\\nMIIEwjCC...\\n-----END CERTIFICATE-----\\n", + ) + payload = req.dict() + resp = await client.post("/api/v1/product/interop/fake", json=payload) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_invalid_tgt(ginosession: None, unauth_client: TestClient) -> None: + """Test requesting interop with product 'nosuch'""" + _ = ginosession + client = unauth_client + client.headers.update({"X-ClientCert-DN": "CN=interoptest.localmaeher.dev.pvarki.fi,O=N/A"}) + req = ProductAddRequest( + certcn="interoptest.localmaeher.dev.pvarki.fi", + x509cert="-----BEGIN CERTIFICATE-----\\nMIIEwjCC...\\n-----END CERTIFICATE-----\\n", + ) + payload = req.dict() + resp = await client.post("/api/v1/product/interop/nosuch", json=payload) + assert resp.status_code == 404 From 95b84cb00311e9972288641f5a7715aed8f99a83 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Sun, 29 Jun 2025 14:46:04 +0300 Subject: [PATCH 7/7] fix: "not in" is more pythonic --- src/rasenmaeher_api/web/api/product/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rasenmaeher_api/web/api/product/views.py b/src/rasenmaeher_api/web/api/product/views.py index f56ffe3..41d726e 100644 --- a/src/rasenmaeher_api/web/api/product/views.py +++ b/src/rasenmaeher_api/web/api/product/views.py @@ -159,7 +159,7 @@ async def add_interop( if "products" not in manifest: LOGGER.error("Manifest does not have products key") raise HTTPException(status_code=500, detail="Manifest does not have products key") - if not tgtproduct in manifest["products"]: + if tgtproduct not in manifest["products"]: raise HTTPException(status_code=404, detail=f"Unknown product {tgtproduct}") resp = await post_to_product(tgtproduct, "/api/v1/interop/add", srcproduct.dict(), OperationResultResponse) if resp is None: