Commit 42bb7350 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: implement realm setup script for Keycloak configuration and API product management

parent ba441b0d
Loading
Loading
Loading
Loading
+41 −5
Changes for oop-platform-chart/charts/keycloak/README.md: 41 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -153,19 +153,52 @@ organization's job ([section 9](#9-organizations-and-tenants)).

### CAMARA scopes

Portal tokens also carry one scope per CAMARA operation (QoD, Edge Application Management,
Location Retrieval, Traffic Influence), named as in each API's OpenAPI `security` block. The list lives in
There is one scope per CAMARA operation (QoD, Edge Application Management, Location Retrieval,
Traffic Influence), named as in each API's OpenAPI `security` block. The list lives in
[`files/camara-scopes.yaml`](files/camara-scopes.yaml): add an entry there when OEG gains an
operation. On an existing installation, also create the scope in the admin console and add it to
`oop-portal` as **Default** ([Change realm settings after install](#change-realm-settings-after-install)).
operation, and add the scope to a product in `global.apiProducts`. On an existing installation, also create the scope in the admin
console and add it to `oop-portal` as **Default**
([Change realm settings after install](#change-realm-settings-after-install)).

A token carries a scope only if its holder has the matching API product, as explained next.

### API products

A new user can call **no** CAMARA API until they are granted a product. A product is
one API with its scopes. In Keycloak it is a **client role** on the `oeg` client:

| Product (role on `oeg`) | Scopes it unlocks |
|---|---|
| `edge-application-management` | the 12 `edge-application-management:*` scopes |
| `quality-on-demand` | `qod-sessions-read`, `-write`, `-delete` |
| `location-retrieval` | `location-retrieval:read` |
| `traffic-influence` | the 5 `traffic-influence:*` scopes |

How it works:

- The list of products and their scopes is `global.apiProducts` in the umbrella `values.yaml`.
  Every scope in `camara-scopes.yaml` must belong to exactly one product.
- Every CAMARA scope is a default scope of `oop-portal`, but each has a **role scope mapping**
  (client scope → **Scope** tab) to its product's role. Keycloak puts the scope in a token only if
  the holder has that role. This holds for people and for a client's service account alike.
- So a user's token carries exactly the scopes of the products they hold. OEG only checks scopes
  and knows nothing about products.
- Changes show up in the **next token**: a token already issued keeps its scopes until it expires
  (5 minutes).
- The `oeg` client's id is fixed (`realms.oop.oeg.internalId`, default `oeg-client`) so the portal
  can be told it. It is an address, not a secret.

To grant a product: admin console → realm `oop` → **Users** → the user → **Role mapping** →
**Assign role** → filter by clients → pick the role under `oeg`.

### Clients

| Client | Kind | Used by | What it does |
|---|---|---|---|
| `oop-portal` | Confidential, Authorization Code + PKCE | Portal | Logs people in. Its tokens name `oeg` in the audience so OEG accepts them, and carry the `organization` claim. |
| `oeg` | No login at all | OEG | Exists only so the audience `oeg` refers to a real client. |
| `oeg` | No login at all | OEG | Makes the audience `oeg` refer to a real client, and owns the API product roles ([API products](#api-products)). |
| `oop-portal-admin` | Service account | Portal backend | Reads and changes users for the Portal's user-management pages. It has only `manage-users`, `view-users`, `query-users` and `view-realm`, so it cannot change realm settings or clients. |
| `oop-portal-provisioner` | Service account | Portal backend | Creates API clients for developers. It has only `create-client`, `view-users` and `query-users`. It does **not** have `view-clients`, which would expose every client's secret. |

`oop-portal` details:

@@ -360,6 +393,7 @@ Everything is generated on the first install and stored in the Secret `keycloak-
| `dev-user-password` | Password of the sample users (only if enabled). |
| `oop-portal-client-secret` | Secret of the Portal's login client. |
| `oop-portal-admin-client-secret` | Secret of the Portal's user-management service account. It can reset any password in realm `oop`: treat it like an admin password. |
| `oop-portal-provisioner-client-secret` | Secret of the Portal's API-client service account. It can register clients in realm `oop`: keep it to the portal backend. |
| `federation-<client id>-secret` | One per partner OP. Federation Manager reads these too. |

Read one:
@@ -552,6 +586,8 @@ existed keeps its old look until you pick it: admin console → realm `oop` →
| Federation Manager checks partner tokens (signature, issuer, audience, scope) | In the FM code; your FM image must include it |
| Portal backend checks bearer tokens and roles | Done (audience check still to switch on) |
| Portal real login (Authorization Code + PKCE, server-side session, logout) | Next: the Portal still has a placeholder login screen |
| API products: roles on `oeg`, scope gate, provisioner client (realm import) | Done for new realms |
| API products: provisioner permissions, registration policies, existing realms | Next: a realm job |
| OEG checks tokens and reads `organization` | Next: OEG does not check tokens yet |
| HTTPS / Ingress for Keycloak | Not yet: `http` + NodePort today; `keycloak.hostname` can already point at an HTTPS address behind your own ingress |

+175 −0
Changes for oop-platform-chart/charts/keycloak/files/realm-setup.py: 175 added lines, 0 removed lines.
Original line number Diff line number Diff line
"""Brings realm oop to the state ADR-0048 needs, whether or not Keycloak imported it from this
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.
"""

import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

CONFIG_FILE = os.environ.get("CONFIG_FILE", "/config/config.json")


class Keycloak:
    def __init__(self, url: str, realm: str, admin: str, password: str) -> None:
        self.url, self.realm, self._admin, self._password = url.rstrip("/"), realm, admin, password
        self._token = ""
        self._renew_at = 0.0

    def _admin_token(self) -> str:
        if time.monotonic() >= self._renew_at:
            body = urllib.parse.urlencode(
                {
                    "grant_type": "password",
                    "client_id": "admin-cli",
                    "username": self._admin,
                    "password": self._password,
                }
            ).encode()
            with urllib.request.urlopen(
                f"{self.url}/realms/master/protocol/openid-connect/token", body, timeout=10
            ) as response:
                self._token = json.load(response)["access_token"]
            self._renew_at = time.monotonic() + 30
        return self._token

    def call(self, method: str, path: str, body: object = None) -> object:
        request = urllib.request.Request(
            f"{self.url}/admin/realms/{self.realm}{path}",
            data=None if body is None else json.dumps(body).encode(),
            method=method,
            headers={
                "Authorization": f"Bearer {self._admin_token()}",
                "Content-Type": "application/json",
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                raw = response.read()
        except urllib.error.HTTPError as exc:
            raise SystemExit(f"{method} {path} -> {exc.code} {exc.read().decode()[:300]}") from exc
        return json.loads(raw) if raw else None

    def get(self, path: str) -> object:
        return self.call("GET", path)

    def wait_until_ready(self) -> None:
        for _ in range(120):
            try:
                with urllib.request.urlopen(f"{self.url}/realms/{self.realm}", timeout=5):
                    self._admin_token()
                    return
            except (urllib.error.URLError, OSError, KeyError):
                print(f"waiting for realm {self.realm} at {self.url}", flush=True)
                time.sleep(5)
        raise SystemExit("Keycloak did not become ready")


def say(action: str, what: str) -> None:
    print(f"{action:8} {what}", flush=True)


def ensure_oeg(kc: Keycloak, cfg: dict) -> str:
    """The `oeg` client, under the id the portal is configured with. Returns that id."""
    found = kc.get("/clients?clientId=oeg")
    if not found:
        kc.call(
            "POST",
            "/clients",
            {
                "id": cfg["oegId"],
                "clientId": "oeg",
                "name": "Open Exposure Gateway",
                "publicClient": False,
                "standardFlowEnabled": False,
                "implicitFlowEnabled": False,
                "directAccessGrantsEnabled": False,
                "serviceAccountsEnabled": False,
            },
        )
        say("created", f"client oeg ({cfg['oegId']})")
    elif found[0]["id"] != cfg["oegId"]:
        raise SystemExit(
            f"client oeg has the id {found[0]['id']}, not {cfg['oegId']}: "
            f"set keycloak.realms.oop.oeg.internalId to {found[0]['id']}"
        )
    else:
        say("ok", "client oeg")
    return cfg["oegId"]


def ensure_product_roles(kc: Keycloak, oeg: str, cfg: dict) -> None:
    have = {role["name"] for role in kc.get(f"/clients/{oeg}/roles")}
    for product in cfg["products"]:
        if product["name"] in have:
            say("ok", f"role {product['name']}")
            continue
        kc.call(
            "POST",
            f"/clients/{oeg}/roles",
            {"name": product["name"], "description": f"API product {product['name']} (ADR-0048)"},
        )
        say("created", f"role {product['name']}")


def ensure_scopes_gated(kc: Keycloak, oeg: str, cfg: dict) -> None:
    """Every CAMARA scope exists, is a default scope of the portal client, and is issued only
    to holders of its product's role."""
    portal = kc.get(f"/clients?clientId={cfg['portalClientId']}")[0]["id"]
    defaults = {s["name"] for s in kc.get(f"/clients/{portal}/default-client-scopes")}
    scope_ids = {s["name"]: s["id"] for s in kc.get("/client-scopes")}
    for product in cfg["products"]:
        role = kc.get(f"/clients/{oeg}/roles/{urllib.parse.quote(product['name'])}")
        for scope in product["scopes"]:
            if scope not in scope_ids:
                kc.call(
                    "POST",
                    "/client-scopes",
                    {
                        "name": scope,
                        "protocol": "openid-connect",
                        "attributes": {"include.in.token.scope": "true"},
                    },
                )
                scope_ids[scope] = next(
                    s["id"] for s in kc.get("/client-scopes") if s["name"] == scope
                )
                say("created", f"scope {scope}")
            mapped = {r["name"] for r in kc.get(
                f"/client-scopes/{scope_ids[scope]}/scope-mappings/clients/{oeg}"
            )}
            if product["name"] not in mapped:
                kc.call(
                    "POST",
                    f"/client-scopes/{scope_ids[scope]}/scope-mappings/clients/{oeg}",
                    [role],
                )
                say("gated", f"{scope} -> {product['name']}")
            if scope not in defaults:
                kc.call("PUT", f"/clients/{portal}/default-client-scopes/{scope_ids[scope]}")
                say("added", f"{scope} to {cfg['portalClientId']}")


def main() -> None:
    cfg = json.load(open(CONFIG_FILE))
    kc = Keycloak(
        os.environ["KEYCLOAK_URL"],
        cfg["realm"],
        os.environ["KEYCLOAK_ADMIN_USERNAME"],
        os.environ["KEYCLOAK_ADMIN_PASSWORD"],
    )
    kc.wait_until_ready()
    oeg = ensure_oeg(kc, cfg)
    ensure_product_roles(kc, oeg, cfg)
    ensure_scopes_gated(kc, oeg, cfg)
    say("done", "")


if __name__ == "__main__":
    sys.exit(main())