Commit 9c8ffd99 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: add provisioner client management and secret validation in realm setup

parent 42bb7350
Loading
Loading
Loading
Loading
+71 −1
Changes for oop-platform-chart/charts/keycloak/files/realm-setup.py: 71 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -2,7 +2,7 @@
chart. Every step only does what is missing, so running the job again changes nothing.

Reads /config/config.json (rendered by the chart) and these environment variables:
KEYCLOAK_URL, KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD.
KEYCLOAK_URL, KEYCLOAK_ADMIN_USERNAME, KEYCLOAK_ADMIN_PASSWORD, PROVISIONER_CLIENT_SECRET.
"""

import json
@@ -156,6 +156,73 @@ def ensure_scopes_gated(kc: Keycloak, oeg: str, cfg: dict) -> None:
                say("added", f"{scope} to {cfg['portalClientId']}")


PROVISIONER_ROLES = {"create-client", "view-users", "query-users"}
# Would expose every client's secret, the oop-portal-admin one included.
FORBIDDEN_PROVISIONER_ROLES = {"view-clients"}


def ensure_provisioner(kc: Keycloak, cfg: dict, secret: str) -> str:
    """The client the portal creates API clients with, and exactly the roles it may hold.
    Returns the client's id."""
    client_id = cfg["provisionerClientId"]
    found = kc.get(f"/clients?clientId={client_id}")
    if found:
        say("ok", f"client {client_id}")
        provisioner = found[0]["id"]
    else:
        kc.call(
            "POST",
            "/clients",
            {
                "clientId": client_id,
                "name": "OOP Portal API client provisioning",
                "description": "Service account for creating API clients (ADR-0048); no login flows.",
                "publicClient": False,
                "clientAuthenticatorType": "client-secret",
                "secret": secret,
                "standardFlowEnabled": False,
                "implicitFlowEnabled": False,
                "directAccessGrantsEnabled": False,
                "serviceAccountsEnabled": True,
            },
        )
        provisioner = kc.get(f"/clients?clientId={client_id}")[0]["id"]
        say("created", f"client {client_id}")

    account = kc.get(f"/clients/{provisioner}/service-account-user")["id"]
    management = kc.get("/clients?clientId=realm-management")[0]["id"]
    path = f"/users/{account}/role-mappings/clients/{management}"
    held = {role["name"]: role for role in kc.get(path)}
    available = {role["name"]: role for role in kc.get(f"/clients/{management}/roles")}
    for name in sorted(PROVISIONER_ROLES - held.keys()):
        kc.call("POST", path, [available[name]])
        say("granted", f"{name} to {client_id}")
    for name in sorted(FORBIDDEN_PROVISIONER_ROLES & held.keys()):
        kc.call("DELETE", path, [held[name]])
        say("removed", f"{name} from {client_id}")
    return provisioner


def check_provisioner_secret(kc: Keycloak, cfg: dict, secret: str) -> None:
    body = urllib.parse.urlencode(
        {
            "grant_type": "client_credentials",
            "client_id": cfg["provisionerClientId"],
            "client_secret": secret,
        }
    ).encode()
    try:
        urllib.request.urlopen(
            f"{kc.url}/realms/{kc.realm}/protocol/openid-connect/token", body, timeout=10
        ).close()
    except urllib.error.HTTPError as exc:
        raise SystemExit(
            f"{cfg['provisionerClientId']} refuses the secret in the credentials Secret "
            f"({exc.code}): change the client's secret in Keycloak to match it"
        ) from exc
    say("ok", "provisioner secret")


def main() -> None:
    cfg = json.load(open(CONFIG_FILE))
    kc = Keycloak(
@@ -168,6 +235,9 @@ def main() -> None:
    oeg = ensure_oeg(kc, cfg)
    ensure_product_roles(kc, oeg, cfg)
    ensure_scopes_gated(kc, oeg, cfg)
    secret = os.environ["PROVISIONER_CLIENT_SECRET"]
    ensure_provisioner(kc, cfg, secret)
    check_provisioner_secret(kc, cfg, secret)
    say("done", "")