api: implement automatic ssh key fetching from keycloak
All checks were successful
Build Container / Build Container (push) Successful in 1m26s

This commit is contained in:
lilly 2026-05-31 22:02:39 +02:00
commit 914a4497c7
Signed by: lilly
SSH key fingerprint: SHA256:y9T5GFw2A20WVklhetIxG1+kcg/Ce0shnQmbu1LQ37g
6 changed files with 111 additions and 0 deletions

View file

@ -1,5 +1,6 @@
import os
import logging
from pathlib import Path
from contextvars import ContextVar
from argparse import ArgumentParser, Namespace
@ -80,6 +81,18 @@ def main():
action="append",
default=[i for i in os.environ.get("DOORIS_STATIC_API_TOKENS", "").split(",") if bool(i)],
)
argp.add_argument(
"--kc-ssh-attr-group",
required=False,
default=os.environ.get("DOORIS_KC_SSH_ATTR_GROUP", "dooris-ssh-keys"),
)
argp.add_argument(
"--authorized-keys-file",
required="DOORIS_AUTHORIZED_KEYS_FILE" not in os.environ,
default=os.environ.get("DOORIS_AUTHORIZED_KEYS_FILE", None),
type=Path,
help="A file to which ssh authorized keys fetched from keycloak will be written",
)
args = argp.parse_args()
# setup logging

View file

@ -13,6 +13,7 @@ from aiohttp import BasicAuth
from dooris_api import deps, models, exceptions, app_config
from dooris_api.ccujack import CCUJackClient
from dooris_api.keycloak import KeycloakClient
logger = logging.getLogger(__name__)
@ -37,9 +38,18 @@ async def lifespan(app: FastAPI):
)
await app.extra["ccujack"].start()
app.extra["keycloak"] = KeycloakClient(
base_uri=app_cfg.openid_issuer,
oidc_client=app.extra["oidc_client"],
keycloak_attribute_group=app_cfg.kc_ssh_attr_group,
authorized_keys_file=app_cfg.authorized_keys_file,
)
app.extra["keycloak"].start()
yield
await app.extra["ccujack"].close_connections()
await app.extra["keycloak"].stop()
app = FastAPI(

View file

@ -166,6 +166,9 @@ class CCUJackClient:
await self.find_locks()
except asyncio.CancelledError:
logger.info("CCUJack task cron stopped")
raise
except Exception as e:
logger.exception(f"Error in CCUJack cron task: {e}")
finally:

View file

@ -0,0 +1,83 @@
from typing import List
from aiohttp import ClientSession, ClientRequest, ClientHandlerType, ClientResponse
import asyncio
import logging
from pathlib import Path
from simple_openid_connect.client import OpenidClient
from simple_openid_connect.data import TokenErrorResponse
logger = logging.getLogger(__name__)
class ClientCredentialsAuth:
def __init__(self, oidc_client: OpenidClient):
self.oidc_client = oidc_client
async def __call__(self, req: ClientRequest, handler: ClientHandlerType) -> ClientResponse:
resp = self.oidc_client.client_credentials_grant.authenticate()
if isinstance(resp, TokenErrorResponse):
raise Exception(f"Could not authenticate against keycloak with client-credentials-grant: {resp.error} ({resp.error_description})")
req.headers["Authorization"] = f"Bearer {resp.access_token}"
return await handler(req)
class KeycloakClient:
keycloak_attribute_group: str
authorized_keys_file: Path
ssh_keys: List[str]
data_updated: asyncio.Event
task_cron = asyncio.Task
def __init__(self, base_uri: str, oidc_client: OpenidClient, keycloak_attribute_group: str, authorized_keys_file: Path):
self.base_uri: str
self.keycloak_attribute_group = keycloak_attribute_group
self.ssh_keys = []
self.data_updated = asyncio.Event()
self.authorized_keys_file = authorized_keys_file
self.http = ClientSession(
base_url=base_uri,
middlewares=[ClientCredentialsAuth(oidc_client)],
raise_for_status=True,
)
def start(self):
self.task_cron = asyncio.get_running_loop().create_task(self.cron(), name="keycloak-cron")
async def stop(self):
await self.http.close()
self.task_cron.cancel()
self.task_cron = None
async def cron(self):
while True:
try:
logger.info("Running keycloak cron")
await self.fetch_ssh_keys()
await self.write_authorized_keys()
except asyncio.CancelledError:
logger.info("keycloak cron stopped")
raise
except Exception as e:
logger.exception(f"Error in Keycloak cron task: {e}")
finally:
await asyncio.sleep(15 * 60) # 15 minutes
async def fetch_ssh_keys(self):
logger.info("Fetching ssh keys from keycloak")
async with self.http.get(f"./attribute-endpoints-provider/export/{self.keycloak_attribute_group}") as resp:
keys = await resp.json()
assert isinstance(keys, list)
self.ssh_keys = keys
self.data_updated.set()
self.data_updated.clear()
async def write_authorized_keys(self):
logger.info(f"Writing authorized keys to {self.authorized_keys_file.absolute()}")
with self.authorized_keys_file.open(mode="wt", encoding="UTF-8") as f:
f.writelines(self.ssh_keys)