From ca57716a2322d7441d03611c554e33792cd8095d Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Fri, 27 Jun 2025 22:13:43 +0300 Subject: [PATCH 1/4] fix: make sure user CRUD requests come from RASENMAEHER --- src/takrmapi/api/helpers.py | 12 ++++++++++++ src/takrmapi/api/usercrud.py | 20 +++++++++++++------- src/takrmapi/config.py | 1 + tests/conftest.py | 2 +- 4 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 src/takrmapi/api/helpers.py diff --git a/src/takrmapi/api/helpers.py b/src/takrmapi/api/helpers.py new file mode 100644 index 00000000..d90dfdea --- /dev/null +++ b/src/takrmapi/api/helpers.py @@ -0,0 +1,12 @@ +"""General helpers""" + +from fastapi import Request, HTTPException + +from .. import config + + +def comes_from_rm(request: Request) -> None: + """Check the CN, raises 403 if not""" + payload = request.state.mtlsdn + if payload.get("CN") != config.RMCN: + raise HTTPException(status_code=403) diff --git a/src/takrmapi/api/usercrud.py b/src/takrmapi/api/usercrud.py index 0f5baa16..56d6a61b 100644 --- a/src/takrmapi/api/usercrud.py +++ b/src/takrmapi/api/usercrud.py @@ -1,12 +1,13 @@ """"User actions""" import logging -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from libpvarki.middleware import MTLSHeader from libpvarki.schemas.product import UserCRUDRequest from libpvarki.schemas.generic import OperationResultResponse -from takrmapi import tak_helpers +from .. import tak_helpers +from .helpers import comes_from_rm LOGGER = logging.getLogger(__name__) @@ -16,8 +17,9 @@ @router.post("/created") -async def user_created(user: UserCRUDRequest) -> OperationResultResponse: +async def user_created(user: UserCRUDRequest, request: Request) -> OperationResultResponse: """New device cert was created""" + comes_from_rm(request) tak_usercrud = tak_helpers.UserCRUD(user) LOGGER.info("Adding new user '{}' to TAK".format(user.callsign)) await tak_usercrud.add_new_user() @@ -29,8 +31,9 @@ async def user_created(user: UserCRUDRequest) -> OperationResultResponse: # While delete would be semantically better it takes no body and definitely forces the # integration layer to keep track of UUIDs @router.post("/revoked") -async def user_revoked(user: UserCRUDRequest) -> OperationResultResponse: +async def user_revoked(user: UserCRUDRequest, request: Request) -> OperationResultResponse: """Device cert was revoked""" + comes_from_rm(request) tak_usercrud = tak_helpers.UserCRUD(user) LOGGER.info("Removing user '{}' from TAK".format(user.callsign)) await tak_usercrud.revoke_user() @@ -39,8 +42,9 @@ async def user_revoked(user: UserCRUDRequest) -> OperationResultResponse: @router.post("/promoted") -async def user_promoted(user: UserCRUDRequest) -> OperationResultResponse: +async def user_promoted(user: UserCRUDRequest, request: Request) -> OperationResultResponse: """Device cert was promoted to admin privileges""" + comes_from_rm(request) tak_usercrud = tak_helpers.UserCRUD(user) LOGGER.info("Promoting user '{}' to admin".format(user.callsign)) await tak_usercrud.promote_user() @@ -49,8 +53,9 @@ async def user_promoted(user: UserCRUDRequest) -> OperationResultResponse: @router.post("/demoted") -async def user_demoted(user: UserCRUDRequest) -> OperationResultResponse: +async def user_demoted(user: UserCRUDRequest, request: Request) -> OperationResultResponse: """Device cert was demoted to standard privileges""" + comes_from_rm(request) tak_usercrud = tak_helpers.UserCRUD(user) LOGGER.info("Demoting user '{}' to normal user".format(user.callsign)) await tak_usercrud.demote_user() @@ -59,8 +64,9 @@ async def user_demoted(user: UserCRUDRequest) -> OperationResultResponse: @router.put("/updated") -async def user_updated(user: UserCRUDRequest) -> OperationResultResponse: +async def user_updated(user: UserCRUDRequest, request: Request) -> OperationResultResponse: """Device callsign updated""" + comes_from_rm(request) tak_usercrud = tak_helpers.UserCRUD(user) await tak_usercrud.update_user() result = OperationResultResponse(success=True) diff --git a/src/takrmapi/config.py b/src/takrmapi/config.py index 28ef40e0..f6da75c1 100644 --- a/src/takrmapi/config.py +++ b/src/takrmapi/config.py @@ -75,3 +75,4 @@ def read_deployment_name() -> str: "TAK_SERVER_NETWORKMESH_KEY_FILE", cast=Path, default=Path("/opt/tak/data/tak_server_networkmesh") ) TAK_SERVER_NETWORKMESH_KEY_STR: str = "" +RMCN: str = cfg("RMCN", cast=str, default="rasenmaeher") # expected CN for RASENMAEHERs mTLS cert diff --git a/tests/conftest.py b/tests/conftest.py index 1ec9dc8e..bbcfffa6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,7 +24,7 @@ def mtlsclient() -> Generator[TestClient, None, None]: client = TestClient( APP, headers={ - "X-ClientCert-DN": "CN=harjoitus1.pvarki.fi,O=harjoitus1.pvarki.fi,L=KeskiSuomi,ST=Jyvaskyla,C=FI", + "X-ClientCert-DN": "CN=rasenmaeher,O=harjoitus1.pvarki.fi,L=KeskiSuomi,ST=Jyvaskyla,C=FI", }, ) yield client From 61174c56a5d2c96669e96e86254ed98516121cb4 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Fri, 27 Jun 2025 22:18:17 +0300 Subject: [PATCH 2/4] fix: remove the REMOVEMEs --- src/takrmapi/api/__init__.py | 2 -- src/takrmapi/api/usercrud.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/takrmapi/api/__init__.py b/src/takrmapi/api/__init__.py index 7577de89..97ac3cb8 100644 --- a/src/takrmapi/api/__init__.py +++ b/src/takrmapi/api/__init__.py @@ -3,7 +3,6 @@ from fastapi.routing import APIRouter from .usercrud import router as usercrud_router -from .usercrud import test_router as testing_router # REMOVE ME from .clientinfo import router as clientinfo_router from .admininfo import router as admininfo_router from .healthcheck import router as healthcheck_router @@ -12,7 +11,6 @@ from .tak_datapackage import router as takdatapackage_router all_routers = APIRouter() -all_routers.include_router(testing_router, prefix="/users", tags=["users"]) # REMOVE ME all_routers.include_router(usercrud_router, prefix="/users", tags=["users"]) all_routers.include_router(clientinfo_router, prefix="/clients", tags=["clients"]) all_routers.include_router(admininfo_router, prefix="/admins", tags=["admins"]) diff --git a/src/takrmapi/api/usercrud.py b/src/takrmapi/api/usercrud.py index 56d6a61b..93221378 100644 --- a/src/takrmapi/api/usercrud.py +++ b/src/takrmapi/api/usercrud.py @@ -13,8 +13,6 @@ router = APIRouter(dependencies=[Depends(MTLSHeader(auto_error=True))]) -test_router = APIRouter() - @router.post("/created") async def user_created(user: UserCRUDRequest, request: Request) -> OperationResultResponse: From e01c1dc41141ab8fa7799800487d940fffefd87a Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Sat, 28 Jun 2025 00:38:03 +0300 Subject: [PATCH 3/4] feat: Add new product intergrations api interop endpoints --- src/takrmapi/api/interop.py | 148 ++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/takrmapi/api/interop.py diff --git a/src/takrmapi/api/interop.py b/src/takrmapi/api/interop.py new file mode 100644 index 00000000..5c3acfbf --- /dev/null +++ b/src/takrmapi/api/interop.py @@ -0,0 +1,148 @@ +"""Routes for interoperation between products""" + +from typing import Sequence, Optional +import logging +from pathlib import Path + +from fastapi import APIRouter, Depends, Request, HTTPException +from libpvarki.middleware import MTLSHeader +from libpvarki.schemas.generic import OperationResultResponse +from libpvarki.schemas.product import UserCRUDRequest +from pydantic import Field, BaseModel, ConfigDict, Extra # pylint: disable=E0611 # False positive + + +from .helpers import comes_from_rm +from ..tak_helpers import UserCRUD as TAKBaseCRUD +from ..tak_helpers import Helpers as TAKBaseHelper +from .. import config + + +LOGGER = logging.getLogger(__name__) + +interoprouter = APIRouter(dependencies=[Depends(MTLSHeader(auto_error=True))]) + + +# 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)") + + model_config = ConfigDict( + extra=Extra.forbid, + schema_extra={ + "examples": [ + { + "certcn": "product.deployment.tld", + "x509cert": "-----BEGIN CERTIFICATE-----\\nMIIEwjCC...\\n-----END CERTIFICATE-----\\n", + }, + ], + }, + ) + + +# FIXME: Move to libpvarki +class ProductAuthzResponse(BaseModel): # pylint: disable=too-few-public-methods + """Authz info""" + + type: str = Field(description="type of authz: bearer-token, basic, mtls") + token: Optional[str] = Field(description="Bearer token", default=None) + username: Optional[str] = Field(description="Username for basic auth", default=None) + password: Optional[str] = Field(description="Password for basic auth", default=None) + + model_config = ConfigDict( + extra=Extra.forbid, + schema_extra={ + "examples": [ + { + "type": "mtls", + }, + { + "type": "bearer-token", + "token": "", + }, + { + "type": "basic", + "username": "product.deployment.tld", + "password": "", + }, + ], + }, + ) + + +def cn_to_callsign(certcn: str) -> str: + """Convert CN to something that is valid as callsign filename""" + return certcn.replace(".", "_") + + +class ProductCRUD(TAKBaseCRUD): + """Pretend to be user""" + + def __init__(self, user: UserCRUDRequest): + super().__init__(user) + self.helpers = TakProductHelper(self) + self.write_cert_conditional() + + @property + def certpath(self) -> Path: + """Path to local cert""" + return config.TAK_CERTS_FOLDER / f"{self.certcn}_rm.pem" + + def write_cert_conditional(self) -> None: + """write the X509 cert if it did not exist""" + if self.certpath.exists(): + return + if self.user.x509cert == "NOTHERE": + raise HTTPException(status_code=404, detail="Certificate not set") + self.certpath.parent.mkdir(parents=True, exist_ok=True) + self.certpath.write_text(self.rm_certpem) + + +class TakProductHelper(TAKBaseHelper): # pylint: disable=too-few-public-methods + """Do TAK things""" + + @property + def enable_user_cert_names(self) -> Sequence[str]: + """Return the stems for cert PEM files""" + return (f"{self.user.callsign}_rm",) + + +@interoprouter.post("/add") +async def add_product( + product: ProductAddRequest, + request: Request, +) -> OperationResultResponse: + """Product needs interop privileges. This can only be called by RASENMAEHER""" + comes_from_rm(request) + callsign = cn_to_callsign(product.certcn) + product_user = UserCRUDRequest( + uuid=callsign, + callsign=callsign, + x509cert=product.x509cert, + ) + crud = ProductCRUD(product_user) + res = await crud.helpers.add_admin_to_tak_with_cert() + if not res: + return OperationResultResponse(success=False, error="TAK admin script failed") + return OperationResultResponse(success=True) + + +@interoprouter.get("/authz") +async def get_authz( + request: Request, +) -> ProductAuthzResponse: + """Get authz info for the product, for tak it's always mtls""" + payload = request.state.mtlsdn + callsign = cn_to_callsign(payload.get("CN")) + product_user = UserCRUDRequest( + uuid=callsign, + callsign=callsign, + x509cert="NOTHERE", + ) + crud = ProductCRUD(product_user) + if not crud.certpath.exists(): + raise HTTPException(status_code=404, detail="Certificate does not exist") + result = ProductAuthzResponse(type="mtls") + return result From 8b43da23264ee2e11f60377681d1ba38e1a53360 Mon Sep 17 00:00:00 2001 From: Eero af Heurlin Date: Fri, 5 Sep 2025 23:11:00 +0300 Subject: [PATCH 4/4] chore: bump version --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- src/takrmapi/__init__.py | 2 +- tests/test_takrmapi.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index bff2d79d..39d0d8b3 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.6.0 +current_version = 1.7.0 commit = False tag = False diff --git a/pyproject.toml b/pyproject.toml index fe570cfa..6171cd7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "takrmapi" -version = "1.6.0" +version = "1.7.0" description = "RASENMAEHER integration API for TAK server" authors = ["Eero af Heurlin ", "Ari Karhunen "] homepage = "https://github.com/pvarki/python-tak-rmapi" diff --git a/src/takrmapi/__init__.py b/src/takrmapi/__init__.py index 30468a09..2c200259 100644 --- a/src/takrmapi/__init__.py +++ b/src/takrmapi/__init__.py @@ -1,3 +1,3 @@ """ RASENMAEHER integration API for TAK server """ -__version__ = "1.6.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly +__version__ = "1.7.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly diff --git a/tests/test_takrmapi.py b/tests/test_takrmapi.py index 3308e1c1..529b820f 100644 --- a/tests/test_takrmapi.py +++ b/tests/test_takrmapi.py @@ -5,4 +5,4 @@ def test_version() -> None: """Make sure version matches expected""" - assert __version__ == "1.6.0" + assert __version__ == "1.7.0"