Skip to content

Make it possible for product integration APIs to enable admin privileges for themselves to another product #124

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

Merged
merged 7 commits into from
Jun 29, 2025
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
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 1.7.0
current_version = 1.8.0
commit = False
tag = False

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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>",
Expand Down
2 changes: 1 addition & 1 deletion src/rasenmaeher_api/__init__.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions src/rasenmaeher_api/web/api/product/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
}
30 changes: 29 additions & 1 deletion src/rasenmaeher_api/web/api/product/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Product registration API views."""

from typing import cast
import logging

from fastapi import APIRouter, Depends, HTTPException, Request
Expand All @@ -11,14 +12,15 @@
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
from ....cfssl.private import sign_csr, revoke_pem
from ....cfssl.base import CFSSLError
from ....rmsettings import RMSettings
from ....kchelpers import KCClient
from ....productapihelpers import post_to_product


router = APIRouter()
Expand Down Expand Up @@ -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 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:
return OperationResultResponse(success=False, error="post_to_product returned None")
resp = cast(OperationResultResponse, resp)
return resp
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
}
)
Expand Down
31 changes: 31 additions & 0 deletions tests/ptfpapi/fprun.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,29 @@
UserInstructionFragment,
UserCRUDRequest,
)
from pydantic import BaseModel, Field, Extra


# FIXME: Move to libpvarki
class ProductAddRequest(BaseModel): # pylint: disable=too-few-public-methods,R0801
"""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__)

Expand Down Expand Up @@ -128,6 +151,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")
Expand All @@ -146,6 +176,7 @@ def main() -> int:
[
web.get("/", handle_get_hello),
web.get("/{name}", handle_get_hello),
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),
Expand Down
55 changes: 55 additions & 0 deletions tests/test_interop.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion tests/test_rasenmaeher_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down