Skip to content
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

Tilauspalvelu integration #92

Merged
merged 9 commits into from
Feb 10, 2024
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.1.1
current_version = 1.2.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.1.1"
version = "1.2.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,2 +1,2 @@
""" python-rasenmaeher-api """
__version__ = "1.1.1" # NOTE Use `bump2version --config-file patch` to bump versions correctly
__version__ = "1.2.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly
118 changes: 79 additions & 39 deletions src/rasenmaeher_api/jwtinit.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,79 @@
import asyncio
from pathlib import Path
import random
import urllib.request
import ssl

import filelock
from multikeyjwt import Issuer
from multikeyjwt.keygen import generate_keypair
from libpvarki.mtlshelp.context import get_ca_context

from .rmsettings import RMSettings

LOGGER = logging.getLogger(__name__)
DEFAULT_KEY_PATH = Path("/data/persistent/private/rasenmaeher_jwt.key")
DEFAULT_PUB_PATH = Path("/data/persistent/public/rasenmaeher_jwt.pub")
KRAFTWERK_KEYS_PATH = Path(os.environ.get("PVARKI_PUBLICKEYS_PATH", "/pvarki/publickeys"))
HTTP_TIMEOUT = 2.0


def check_public_keys() -> bool:
"""Check public keys"""
def _check_public_keys_tilauspalvelu(pubkeydir: Path) -> None:
"""handle TILAUSPALVELU public key"""
tppubkey = pubkeydir / "tilauspalvelu.pub"
if tppubkey.exists():
LOGGER.debug("{} exists".format(tppubkey))
return
if not RMSettings.singleton().tilauspalvelu_jwt:
LOGGER.info("No URL for TILAUSPALVELU public key given")
return
LOGGER.info("Making sure TILAUSPALVELU key is in {}".format(pubkeydir))
lockpath = pubkeydir.parent / "tpkeycopy.lock"
lock = filelock.FileLock(lockpath)
try:
lock.acquire(timeout=0.0)
ssl_ctx = get_ca_context(ssl.Purpose.SERVER_AUTH)
try:
with urllib.request.urlopen(
RMSettings.singleton().tilauspalvelu_jwt, context=ssl_ctx, timeout=HTTP_TIMEOUT # nosec
) as response:
tppubkey.write_bytes(response.read())
except (urllib.request.HTTPError, TimeoutError) as exc:
LOGGER.error("Could not load TILAUSPALVELU key: {}".format(exc))
except Exception as exc: # pylint: disable=W0718
LOGGER.exception("Unhanled exception while loading TILAUSPALVELU key: {}".format(exc))
except filelock.Timeout:
LOGGER.info("Someone already locked {}, leaving them to it".format(lockpath))
finally:
lock.release()


def _check_public_keys_kraftwerk(pubkeydir: Path) -> None:
"""Handle KRAFTWERK Public Keys copy"""
if not KRAFTWERK_KEYS_PATH.exists():
LOGGER.warning("{} does not exist, not copying KRAFTWERK public keys".format(KRAFTWERK_KEYS_PATH))
return
LOGGER.info("Making sure KRAFTWERK provided keys are in {}".format(pubkeydir))
lockpath = pubkeydir.parent / "pubkeycopy.lock"
lock = filelock.FileLock(lockpath)
try:
lock.acquire(timeout=0.0)
for fpath in KRAFTWERK_KEYS_PATH.iterdir():
tgtpath = pubkeydir / fpath.name
LOGGER.debug("Checking {} vs {} (exists={})".format(fpath, tgtpath, tgtpath.exists()))
if tgtpath.exists():
continue
# Copy the pubkey
LOGGER.info("Copying {} to {}".format(fpath, tgtpath))
tgtpath.write_bytes(fpath.read_bytes())
except filelock.Timeout:
LOGGER.info("Someone already locked {}, leaving them to it".format(lockpath))
finally:
lock.release()


def resolve_pubkeydir() -> Path:
"""Resolve the directory for public keys and make sure it exists"""
pubkeydir: Union[Path, Optional[str]] = os.environ.get("JWT_PUBKEY_PATH")
LOGGER.debug("initial pubkeydir={}".format(pubkeydir))
if pubkeydir:
Expand All @@ -29,27 +89,17 @@ def check_public_keys() -> bool:
LOGGER.debug("final pubkeydir={}".format(pubkeydir))
if not pubkeydir.exists():
pubkeydir.mkdir(parents=True)
return pubkeydir


def check_public_keys() -> bool:
"""Check public keys"""
pubkeydir = resolve_pubkeydir()

# FIXME: These should be run in executors (which means this function should be async etc)
_check_public_keys_tilauspalvelu(pubkeydir)
_check_public_keys_kraftwerk(pubkeydir)

if KRAFTWERK_KEYS_PATH.exists():
LOGGER.info("Making sure KRAFTWERK provided keys are in {}".format(pubkeydir))
lockpath = pubkeydir.parent / "pubkeycopy.lock"
lock = filelock.FileLock(lockpath)
try:
lock.acquire(timeout=0.0)
for fpath in KRAFTWERK_KEYS_PATH.iterdir():
tgtpath = pubkeydir / fpath.name
LOGGER.debug("Checking {} vs {} (exists={})".format(fpath, tgtpath, tgtpath.exists()))
if tgtpath.exists():
continue
# Copy the pubkey
LOGGER.info("Copying {} to {}".format(fpath, tgtpath))
tgtpath.write_bytes(fpath.read_bytes())
except filelock.Timeout:
LOGGER.info("Someone already locked {}, leaving them to it".format(lockpath))
finally:
lock.release()
else:
LOGGER.warning("{} does not exist, not copying KRAFTWERK public keys".format(KRAFTWERK_KEYS_PATH))
return True


Expand Down Expand Up @@ -79,7 +129,7 @@ def check_jwt_init() -> bool:
return check_public_keys()


def _jwt_init_keypath() -> Path:
def resolve_rm_jwt_privkey_path() -> Path:
"""resolve the path for the private key"""
keypath: Union[Path, Optional[str]] = os.environ.get("JWT_PRIVKEY_PATH")
if keypath:
Expand All @@ -95,28 +145,18 @@ def _jwt_init_keypath() -> Path:
return keypath


def _jwt_init_pubkeypath(genpubpath: Path) -> Path:
def resolve_rm_jwt_pubkey_path(expect_name: Optional[str] = None) -> Path:
"""resolve the path for the public key"""
pubkeypath: Union[Path, Optional[str]] = os.environ.get("JWT_PUBKEY_PATH")
LOGGER.debug("initial pubkeypath={}".format(pubkeypath))
if pubkeypath:
pubkeypath = Path(pubkeypath)
if pubkeypath.exists() and pubkeypath.is_dir():
pubkeypath = pubkeypath / genpubpath.name
LOGGER.info("JWT_PUBKEY_PATH defined, copying our key there")
else:
pubkeypath = DEFAULT_PUB_PATH
LOGGER.debug("final pubkeypath={}".format(pubkeypath))
if not pubkeypath.parent.exists():
pubkeypath.parent.mkdir(parents=True)
return pubkeypath
if not expect_name:
expect_name = resolve_rm_jwt_privkey_path().name.replace(".key", ".pub")
return resolve_pubkeydir() / expect_name


async def jwt_init() -> None:
"""If needed: Create keypair"""
if check_jwt_init():
return
keypath = _jwt_init_keypath()
keypath = resolve_rm_jwt_privkey_path()

genpubpath: Optional[Path] = None
genprivpath: Optional[Path] = None
Expand Down Expand Up @@ -146,7 +186,7 @@ async def jwt_init() -> None:
raise RuntimeError("Returned private key does not exist!")
if not genpubpath or not genpubpath.exists():
raise RuntimeError("Returned private key does not exist!")
pubkeypath = _jwt_init_pubkeypath(genpubpath)
pubkeypath = resolve_rm_jwt_pubkey_path(genpubpath.name)

LOGGER.debug("Copy generated pubkey to {}".format(pubkeypath))
pubkeypath.write_bytes(genpubpath.read_bytes())
Expand Down
5 changes: 5 additions & 0 deletions src/rasenmaeher_api/rmsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ class Config: # pylint: disable=too-few-public-methods
ldap_username: Optional[str] = None
ldap_client_secret: Optional[str] = None

# Tilauspalvelu integration
tilauspalvelu_jwt: str = "https://tilaa.pvarki.fi/api/v1/config/jwtPublicKey.pem"
kraftwerk_announce: Optional[str] = None # When KRAFTWERK actually exists
kraftwerk_timeout: float = 2.0

_singleton: ClassVar[Optional["RMSettings"]] = None

@classmethod
Expand Down
52 changes: 33 additions & 19 deletions src/rasenmaeher_api/web/api/utils/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import logging

from fastapi import APIRouter, Depends, Response
from fastapi.responses import FileResponse
from libpvarki.middleware.mtlsheader import MTLSHeader


from .schema import LdapConnString, KeyCloakConnString
from ....rmsettings import switchme_to_singleton_call
from ....rmsettings import RMSettings
from ....cfssl.public import get_crl
from ....jwtinit import resolve_rm_jwt_pubkey_path


LOGGER = logging.getLogger(__name__)
Expand All @@ -20,24 +22,25 @@ async def request_utils_ldap_conn_string() -> LdapConnString:
TODO ldap-conn-string
"""

conf = RMSettings.singleton()
if None in (
switchme_to_singleton_call.ldap_conn_string,
switchme_to_singleton_call.ldap_username,
switchme_to_singleton_call.ldap_client_secret,
conf.ldap_conn_string,
conf.ldap_username,
conf.ldap_client_secret,
):
return LdapConnString(
success=False,
reason="One or more ldap connection variables are undefined.",
ldap_conn_string=switchme_to_singleton_call.ldap_conn_string,
ldap_user=switchme_to_singleton_call.ldap_username,
ldap_client_secret=switchme_to_singleton_call.ldap_client_secret,
ldap_conn_string=conf.ldap_conn_string,
ldap_user=conf.ldap_username,
ldap_client_secret=conf.ldap_client_secret,
)

return LdapConnString(
success=True,
ldap_conn_string=switchme_to_singleton_call.ldap_conn_string,
ldap_user=switchme_to_singleton_call.ldap_username,
ldap_client_secret=switchme_to_singleton_call.ldap_client_secret,
ldap_conn_string=conf.ldap_conn_string,
ldap_user=conf.ldap_username,
ldap_client_secret=conf.ldap_client_secret,
reason="",
)

Expand All @@ -48,11 +51,12 @@ async def request_utils_keycloak_conn_string() -> KeyCloakConnString:
TODO keycloak-conn-string
"""

conf = RMSettings.singleton()
if None in (
switchme_to_singleton_call.keycloak_server_url,
switchme_to_singleton_call.keycloak_client_id,
switchme_to_singleton_call.keycloak_realm_name,
switchme_to_singleton_call.keycloak_client_secret,
conf.keycloak_server_url,
conf.keycloak_client_id,
conf.keycloak_realm_name,
conf.keycloak_client_secret,
):
return KeyCloakConnString(
success=False,
Expand All @@ -65,16 +69,26 @@ async def request_utils_keycloak_conn_string() -> KeyCloakConnString:

return KeyCloakConnString(
success=True,
keycloak_server_url=switchme_to_singleton_call.keycloak_server_url,
keycloak_client_id=switchme_to_singleton_call.keycloak_client_id,
keycloak_realm_name=switchme_to_singleton_call.keycloak_realm_name,
keycloak_client_s_sting=switchme_to_singleton_call.keycloak_client_secret,
keycloak_server_url=conf.keycloak_server_url,
keycloak_client_id=conf.keycloak_client_id,
keycloak_realm_name=conf.keycloak_realm_name,
keycloak_client_s_sting=conf.keycloak_client_secret,
reason="",
)


@router.get("/crl")
async def return_crl() -> Response:
"""Get the CRL from CFSSL"""
"""Get the CRL from CFSSL. NOTE: This should not be used anymore, use the cosprest helper for CRLs"""
crl_der = await get_crl()
return Response(content=crl_der, media_type="application/pkix-crl")


@router.get("/jwt.pub")
async def return_jwt_pubkey() -> FileResponse:
"""Return PEM encoded public key for tokens that RASENMAEHER issues"""
my_dn = RMSettings.singleton().kraftwerk_manifest_dict["dns"]
deployment_name = my_dn.split(".")[0]
return FileResponse(
resolve_rm_jwt_pubkey_path(), media_type="application/x-pem-file", filename=f"{deployment_name}_rm_jwt.pub"
)
37 changes: 33 additions & 4 deletions src/rasenmaeher_api/web/application.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Main API entrypoint"""
from typing import AsyncGenerator
import asyncio
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI
from libpvarki.logging import init_logging
import aiohttp

from ..rmsettings import switchme_to_singleton_call
from ..rmsettings import RMSettings
from .api.router import api_router
from ..mtlsinit import mtls_init
from ..jwtinit import jwt_init
Expand All @@ -27,14 +29,16 @@ async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
_ = app
await jwt_init()
await mtls_init()
reporter = asyncio.get_running_loop().create_task(report_to_kraftwerk())
# App runs
yield
# Cleanup
await reporter # Just to avoid warning about task that was not awaited
await dbwrapper.app_shutdown_event()


def get_app_no_init() -> FastAPI:
"""Retunr the app without logging etc inits"""
"""Return the app without logging etc inits"""
app = FastAPI(docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=app_lifespan, version=__version__)
app.include_router(router=api_router, prefix="/api/v1")
app.add_middleware(DBConnectionMiddleware, gino=dbbase.db, config=DBConfig.singleton())
Expand All @@ -43,7 +47,32 @@ def get_app_no_init() -> FastAPI:

def get_app() -> FastAPI:
"""Returns the FastAPI application."""
init_logging(switchme_to_singleton_call.log_level_int)
init_logging(RMSettings.singleton().log_level_int)
app = get_app_no_init()
LOGGER.info("API init done, setting log verbosity to '{}'.".format(switchme_to_singleton_call.log_level))
LOGGER.info("API init done, setting log verbosity to '{}'.".format(RMSettings.singleton().log_level))
return app


async def report_to_kraftwerk() -> None:
"""Call the KRAFTWERK announce URL if configured"""
conf = RMSettings.singleton()
url = conf.kraftwerk_announce
if not url:
LOGGER.info("KRAFTWERK announce url is empty")
return
data = {
"dns": conf.kraftwerk_manifest_dict["dns"],
"version": __version__,
}
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=conf.kraftwerk_timeout)) as session:
LOGGER.debug("POSTing to {} data: {}".format(url, data))
async with session.post(url, json=data) as response:
response.raise_for_status()
payload = await response.json()
LOGGER.debug("{} responded with {}".format(url, payload))
except (aiohttp.ClientError, TimeoutError) as exc:
LOGGER.warning("Failed to report to KRAFTWERK at {}".format(url))
LOGGER.info(exc)
except Exception as exc: # pylint: disable=W0718
LOGGER.exception("Unhandled exception while reporting to KRAFTWERK: {}".format(exc))
Loading
Loading