From f9a18723d0e7f4bd00789eb578c49b7b679e370b Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Wed, 28 Oct 2026 14:37:03 +0100 Subject: [PATCH 1/6] feat(keycloak): issue tokens pyro-api can verify - pyro-api client scope: adds pyro-api to aud and the realm roles as a top-level roles claim - pyro-organization claim renamed to organization_id, which avoids Keycloak's built-in Organizations claim - new-pyro-platform gets both scopes by default - pyro-api confidential client, used by the API to introspect tokens - pyro-scripts service account (admin, org 1, 10h tokens) for the init script and notebooks - test-admin user (admin, org 1) --- containers/keycloak/realm/realm_pyronear.json | 140 +++++++++++++++++- 1 file changed, 137 insertions(+), 3 deletions(-) diff --git a/containers/keycloak/realm/realm_pyronear.json b/containers/keycloak/realm/realm_pyronear.json index 946ab48..6d07a43 100644 --- a/containers/keycloak/realm/realm_pyronear.json +++ b/containers/keycloak/realm/realm_pyronear.json @@ -588,6 +588,47 @@ ], "notBefore": 0, "groups": [] + }, + { + "username": "service-account-pyro-scripts", + "enabled": true, + "serviceAccountClientId": "pyro-scripts", + "attributes": { + "pyro-organization": [ + "1" + ] + }, + "realmRoles": [ + "default-roles-pyronear", + "admin" + ], + "groups": [] + }, + { + "username": "test-admin", + "emailVerified": true, + "firstName": "Admin", + "lastName": "Pyronear", + "email": "admin@mail.com", + "attributes": { + "pyro-organization": [ + "1" + ] + }, + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "test", + "temporary": false + } + ], + "requiredActions": [], + "realmRoles": [ + "default-roles-pyronear", + "admin" + ], + "groups": [] } ], "scopeMappings": [ @@ -853,7 +894,9 @@ "profile", "roles", "basic", - "email" + "email", + "pyro-api", + "pyro-organization" ], "optionalClientScopes": [ "address", @@ -974,6 +1017,56 @@ "organization", "microprofile-jwt" ] + }, + { + "clientId": "pyro-scripts", + "name": "Pyronear envdev scripts", + "description": "Service account used by the init script and notebooks (client credentials). Dev-only secret.", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "pyro-scripts-dev-secret", + "publicClient": false, + "bearerOnly": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "fullScopeAllowed": true, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email", + "pyro-api", + "pyro-organization" + ], + "optionalClientScopes": [], + "attributes": { + "access.token.lifespan": "36000" + } + }, + { + "clientId": "pyro-api", + "name": "Pyronear alert API", + "description": "Confidential client the API authenticates as to introspect access tokens. Dev-only secret.", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "pyro-api-dev-secret", + "publicClient": false, + "bearerOnly": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "fullScopeAllowed": false, + "defaultClientScopes": [], + "optionalClientScopes": [] } ], "clientScopes": [ @@ -1556,7 +1649,7 @@ "id.token.claim": "true", "lightweight.claim": "true", "access.token.claim": "true", - "claim.name": "organization", + "claim.name": "organization_id", "jsonType.label": "int" } } @@ -1695,6 +1788,46 @@ } } ] + }, + { + "name": "pyro-api", + "description": "Claims pyro-api relies on: its audience and the user's realm roles", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "include.in.openid.provider.metadata": "false" + }, + "protocolMappers": [ + { + "name": "pyro-api audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.custom.audience": "pyro-api", + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true", + "lightweight.claim": "false" + } + }, + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "claim.name": "roles", + "jsonType.label": "String", + "multivalued": "true", + "access.token.claim": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + } + ] } ], "defaultDefaultClientScopes": [ @@ -1706,7 +1839,8 @@ "web-origins", "acr", "basic", - "pyro-organization" + "pyro-organization", + "pyro-api" ], "defaultOptionalClientScopes": [ "offline_access", From 35cfdc2ad646962bfa8c9a5b3c63846e67322f0f Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Wed, 28 Oct 2026 14:37:03 +0100 Subject: [PATCH 2/6] feat(compose): run Keycloak in the base stack and wire it to the API and scripts - Keycloak leaves the tools profile, pins its issuer with KC_HOSTNAME and reports readiness from /health/ready - pyro_api gets KEYCLOAK_ISSUER, the backchannel URL and the pyro-api client secret - init_script and notebooks get the pyro-scripts credentials, with defaults for .env files created before Keycloak --- .env.test | 9 ++++++++- docker-compose.yml | 43 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/.env.test b/.env.test index ddb0c92..90c34d0 100644 --- a/.env.test +++ b/.env.test @@ -46,4 +46,11 @@ DISTANT_ALERT_API_LOGIN='' DISTANT_ALERT_API_PASSWORD='' KEYCLOAK_USERNAME=admin -KEYCLOAK_PASSWORD=admin_keycloak \ No newline at end of file +KEYCLOAK_PASSWORD=admin_keycloak +# pyro-scripts service account (realm_pyronear.json): admin token for the init script and notebooks. +# The URL is the compose-network one; use http://localhost:9310/... when running scripts from the host. +KEYCLOAK_TOKEN_URL=http://keycloak:9310/realms/pyronear/protocol/openid-connect/token +KEYCLOAK_SCRIPTS_CLIENT_ID=pyro-scripts +KEYCLOAK_SCRIPTS_CLIENT_SECRET=pyro-scripts-dev-secret +# pyro-api client: the API introspects every Keycloak token with it +KEYCLOAK_API_CLIENT_SECRET=pyro-api-dev-secret \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index fc0a14f..3aba27c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,12 @@ services: - TEMPORAL_API_URL=${TEMPORAL_API_URL:-} - TEMPORAL_MODEL_THRESHOLD=${TEMPORAL_MODEL_THRESHOLD:-0.45} - TEMPORAL_API_TOKEN=${TEMPORAL_API_TOKEN:-} + # Tokens carry the browser-facing issuer; the API calls Keycloak (signing keys, introspection) over the + # compose network, authenticated as the pyro-api client. + - KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER:-http://localhost:9310/realms/pyronear} + - KEYCLOAK_BACKCHANNEL_URL=http://keycloak:9310/realms/pyronear + - KEYCLOAK_CLIENT_ID=pyro-api + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_API_CLIENT_SECRET:-pyro-api-dev-secret} healthcheck: test: ["CMD-SHELL", "curl -X 'GET' 'http://pyro_api:5050/status' -H 'accept: application/json'"] interval: 20s @@ -140,8 +146,11 @@ services: image: pyronear/pyro-api-init:latest environment: - API_URL=${API_URL} - - SUPERADMIN_LOGIN=${SUPERADMIN_LOGIN} - - SUPERADMIN_PWD=${SUPERADMIN_PWD} + # Admin token from the pyro-scripts service account (client credentials). + # Defaults match the realm export, so a .env from before Keycloak still works. + - KEYCLOAK_TOKEN_URL=${KEYCLOAK_TOKEN_URL:-http://keycloak:9310/realms/pyronear/protocol/openid-connect/token} + - KEYCLOAK_SCRIPTS_CLIENT_ID=${KEYCLOAK_SCRIPTS_CLIENT_ID:-pyro-scripts} + - KEYCLOAK_SCRIPTS_CLIENT_SECRET=${KEYCLOAK_SCRIPTS_CLIENT_SECRET:-pyro-scripts-dev-secret} - SLACK_HOOK=${SLACK_HOOK} volumes: - ./data/:/data/ @@ -149,6 +158,8 @@ services: depends_on: pyro_api: condition: service_healthy + keycloak: + condition: service_healthy frontend: image: pyronear/pyro-platform-react:latest profiles: @@ -171,6 +182,11 @@ services: - tools ports: - 8889:8888 + environment: + # Same service account as init_script; load_dotenv in the notebooks doesn't override these + - KEYCLOAK_TOKEN_URL=${KEYCLOAK_TOKEN_URL:-http://keycloak:9310/realms/pyronear/protocol/openid-connect/token} + - KEYCLOAK_SCRIPTS_CLIENT_ID=${KEYCLOAK_SCRIPTS_CLIENT_ID:-pyro-scripts} + - KEYCLOAK_SCRIPTS_CLIENT_SECRET=${KEYCLOAK_SCRIPTS_CLIENT_SECRET:-pyro-scripts-dev-secret} volumes: - ./containers/notebooks/app:/app/notebooks - ./data:/app/data @@ -188,8 +204,6 @@ services: - db keycloak: image: quay.io/keycloak/keycloak:26.7.3 - profiles: - - tools ports: - "9310:9310" environment: @@ -197,6 +211,10 @@ services: KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_PASSWORD} KC_HTTP_PORT: 9310 KC_HTTPS_PORT: 8443 + # Same issuer whether a token is requested by the browser (localhost) or a container (keycloak:9310) + KC_HOSTNAME: http://localhost:9310 + KC_HOSTNAME_BACKCHANNEL_DYNAMIC: true + KC_HEALTH_ENABLED: true KC_DB: dev-file KC_ENABLE_HTTPS: false KC_LOG_LEVEL: info @@ -204,11 +222,20 @@ services: - "./containers/keycloak/realm:/opt/keycloak/data/import" - "./containers/keycloak/themes:/opt/keycloak/themes" healthcheck: - test: ["CMD-SHELL", "grep -q 'started' /opt/keycloak/data/log/server.log || exit 1"] - interval: 20s + # Readiness on the management port; the image has no curl, so speak HTTP over bash's /dev/tcp + test: + - CMD + - bash + - -c + - >- + exec 3<>/dev/tcp/127.0.0.1/9000 && + printf 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && + grep -q '"status": "UP"' <&3 + interval: 10s timeout: 5s - retries: 6 - command: start-dev --import-realm --log="console,file" + retries: 12 + start_period: 20s + command: start-dev --import-realm volumes: rustfs_data: From f71bcd57f16e0b7adb305160dde1c11cfa6d8941 Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Wed, 28 Oct 2026 14:37:03 +0100 Subject: [PATCH 3/6] feat(scripts): get admin tokens from the pyro-scripts service account The init script and notebooks use client credentials instead of logging in with the superadmin password. The notebooks' legacy get_token stays for local scripts while pyro-api still accepts API-issued user tokens. --- containers/init_script/init_script.py | 9 ++++---- containers/init_script/utils.py | 21 ++++++++++++------- containers/notebooks/app/api.py | 15 +++++++++++-- containers/notebooks/app/camera_status.ipynb | 11 +++++----- .../notebooks/app/continuous_alert.ipynb | 11 +++++----- .../notebooks/app/custom_azimuth_alert.ipynb | 11 +++++----- .../notebooks/app/send_real_alerts.ipynb | 11 +++++----- containers/notebooks/app/test poses.ipynb | 11 +++++----- 8 files changed, 61 insertions(+), 39 deletions(-) diff --git a/containers/init_script/init_script.py b/containers/init_script/init_script.py index 94f0594..b7d5ba0 100644 --- a/containers/init_script/init_script.py +++ b/containers/init_script/init_script.py @@ -15,7 +15,7 @@ # Import utility functions from utils import ( - get_token, + get_service_token, api_request, read_json_file, write_json_file, @@ -29,15 +29,16 @@ # API URL with /api/v1 for direct API requests api_url = base_url + "/api/v1" -superuser_login = os.environ.get("SUPERADMIN_LOGIN") -superuser_pwd = os.environ.get("SUPERADMIN_PWD") +keycloak_token_url = os.environ.get("KEYCLOAK_TOKEN_URL") +keycloak_client_id = os.environ.get("KEYCLOAK_SCRIPTS_CLIENT_ID") +keycloak_client_secret = os.environ.get("KEYCLOAK_SCRIPTS_CLIENT_SECRET") slack_hook = os.environ.get("SLACK_HOOK") credentials_path = "data/credentials.json" credentials_path_etl = "data/credentials-wildfire.json" superuser_auth = { - "Authorization": f"Bearer {get_token(api_url, superuser_login, superuser_pwd)}", + "Authorization": f"Bearer {get_service_token(keycloak_token_url, keycloak_client_id, keycloak_client_secret)}", "Content-Type": "application/json", } diff --git a/containers/init_script/utils.py b/containers/init_script/utils.py index 11b7b32..7dd212a 100644 --- a/containers/init_script/utils.py +++ b/containers/init_script/utils.py @@ -18,25 +18,30 @@ import requests -def get_token(api_url: str, login: str, pwd: str) -> str: +def get_service_token(token_url: str, client_id: str, client_secret: str) -> str: """ - Authenticate with API and return access token. + Get an access token from Keycloak for a service account (client credentials grant). Args: - api_url: Base API URL (e.g., "http://api:5050/api/v1") - login: Username - pwd: Password + token_url: Realm token endpoint + (e.g., "http://keycloak:9310/realms/pyronear/protocol/openid-connect/token") + client_id: Confidential client with a service account (e.g., "pyro-scripts") + client_secret: Secret of that client Returns: Access token string """ response = requests.post( - f"{api_url}/login/creds", - data={"username": login, "password": pwd}, + token_url, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, timeout=5, ) if response.status_code != 200: - raise ValueError(response.json()["detail"]) + raise ValueError(response.json().get("error_description", response.text)) return response.json()["access_token"] diff --git a/containers/notebooks/app/api.py b/containers/notebooks/app/api.py index e72b62f..9100a5c 100644 --- a/containers/notebooks/app/api.py +++ b/containers/notebooks/app/api.py @@ -8,11 +8,22 @@ import requests -__all__ = ["get_token", "get_camera_token"] +__all__ = ["get_service_token", "get_token", "get_camera_token"] + + +def get_service_token(token_url: str, client_id: str, client_secret: str) -> str: + """Get an access token from Keycloak for a service account (client credentials grant)""" + response = requests.post( + token_url, + data={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret}, + timeout=5, + ) + response.raise_for_status() + return response.json()["access_token"] def get_token(API_URL, login: str, passwrd: str) -> str: - """Authenticate with API and return access token""" + """Legacy: log in with API credentials. Only works while pyro-api accepts API-issued user tokens.""" response = requests.post( urljoin(API_URL, "/api/v1/login/creds"), data={"username": login, "password": passwrd}, diff --git a/containers/notebooks/app/camera_status.ipynb b/containers/notebooks/app/camera_status.ipynb index 400b348..7e3ac01 100644 --- a/containers/notebooks/app/camera_status.ipynb +++ b/containers/notebooks/app/camera_status.ipynb @@ -21,7 +21,7 @@ "import requests\n", "from dotenv import load_dotenv\n", "import os\n", - "from api import get_token, get_camera_token\n", + "from api import get_service_token, get_camera_token\n", "from pyroclient import Client\n", "import glob\n", "from PIL import Image\n", @@ -38,11 +38,12 @@ "source": [ "API_URL = \"http://api:5050\"\n", "load_dotenv(\"../.env\")\n", - "SUPERADMIN_LOGIN = os.environ.get(\"SUPERADMIN_LOGIN\")\n", - "SUPERADMIN_PWD = os.environ.get(\"SUPERADMIN_PWD\")\n", + "KEYCLOAK_TOKEN_URL = os.environ.get(\"KEYCLOAK_TOKEN_URL\")\n", + "KEYCLOAK_SCRIPTS_CLIENT_ID = os.environ.get(\"KEYCLOAK_SCRIPTS_CLIENT_ID\")\n", + "KEYCLOAK_SCRIPTS_CLIENT_SECRET = os.environ.get(\"KEYCLOAK_SCRIPTS_CLIENT_SECRET\")\n", "\n", - "# Get access token\n", - "admin_access_token = get_token(API_URL, SUPERADMIN_LOGIN, SUPERADMIN_PWD)\n", + "# Get an admin access token from Keycloak (pyro-scripts service account)\n", + "admin_access_token = get_service_token(KEYCLOAK_TOKEN_URL, KEYCLOAK_SCRIPTS_CLIENT_ID, KEYCLOAK_SCRIPTS_CLIENT_SECRET)\n", "\n", "api_client = Client(admin_access_token, API_URL)\n", "\n", diff --git a/containers/notebooks/app/continuous_alert.ipynb b/containers/notebooks/app/continuous_alert.ipynb index 634bac0..5b7d402 100644 --- a/containers/notebooks/app/continuous_alert.ipynb +++ b/containers/notebooks/app/continuous_alert.ipynb @@ -17,7 +17,7 @@ "import pandas as pd\n", "from dotenv import load_dotenv\n", "import os\n", - "from api import get_token, get_camera_token\n", + "from api import get_service_token, get_camera_token\n", "from pyroclient import Client\n", "import glob\n", "from PIL import Image\n", @@ -38,11 +38,12 @@ "source": [ "API_URL = \"http://api:5050\"\n", "load_dotenv(\"../.env\")\n", - "SUPERADMIN_LOGIN = os.environ.get(\"SUPERADMIN_LOGIN\")\n", - "SUPERADMIN_PWD = os.environ.get(\"SUPERADMIN_PWD\")\n", + "KEYCLOAK_TOKEN_URL = os.environ.get(\"KEYCLOAK_TOKEN_URL\")\n", + "KEYCLOAK_SCRIPTS_CLIENT_ID = os.environ.get(\"KEYCLOAK_SCRIPTS_CLIENT_ID\")\n", + "KEYCLOAK_SCRIPTS_CLIENT_SECRET = os.environ.get(\"KEYCLOAK_SCRIPTS_CLIENT_SECRET\")\n", "\n", - "# Get access token\n", - "admin_access_token = get_token(API_URL, SUPERADMIN_LOGIN, SUPERADMIN_PWD)\n", + "# Get an admin access token from Keycloak (pyro-scripts service account)\n", + "admin_access_token = get_service_token(KEYCLOAK_TOKEN_URL, KEYCLOAK_SCRIPTS_CLIENT_ID, KEYCLOAK_SCRIPTS_CLIENT_SECRET)\n", "\n", "# Download some relevant images and predictions\n", "if not os.path.isdir(\"selection-true-positives\"):\n", diff --git a/containers/notebooks/app/custom_azimuth_alert.ipynb b/containers/notebooks/app/custom_azimuth_alert.ipynb index 53b1818..825830e 100644 --- a/containers/notebooks/app/custom_azimuth_alert.ipynb +++ b/containers/notebooks/app/custom_azimuth_alert.ipynb @@ -9,7 +9,7 @@ "import pandas as pd\n", "from dotenv import load_dotenv\n", "import os\n", - "from api import get_token, get_camera_token\n", + "from api import get_service_token, get_camera_token\n", "from pyroclient import Client\n", "import glob\n", "from PIL import Image\n", @@ -25,11 +25,12 @@ "source": [ "API_URL = \"http://0.0.0.0:5050\"\n", "load_dotenv(\"../.env\")\n", - "SUPERADMIN_LOGIN = os.environ.get(\"SUPERADMIN_LOGIN\")\n", - "SUPERADMIN_PWD = os.environ.get(\"SUPERADMIN_PWD\")\n", + "KEYCLOAK_TOKEN_URL = os.environ.get(\"KEYCLOAK_TOKEN_URL\")\n", + "KEYCLOAK_SCRIPTS_CLIENT_ID = os.environ.get(\"KEYCLOAK_SCRIPTS_CLIENT_ID\")\n", + "KEYCLOAK_SCRIPTS_CLIENT_SECRET = os.environ.get(\"KEYCLOAK_SCRIPTS_CLIENT_SECRET\")\n", "\n", - "# Get access token\n", - "admin_access_token = get_token(API_URL, SUPERADMIN_LOGIN, SUPERADMIN_PWD)\n" + "# Get an admin access token from Keycloak (pyro-scripts service account)\n", + "admin_access_token = get_service_token(KEYCLOAK_TOKEN_URL, KEYCLOAK_SCRIPTS_CLIENT_ID, KEYCLOAK_SCRIPTS_CLIENT_SECRET)\n" ] }, { diff --git a/containers/notebooks/app/send_real_alerts.ipynb b/containers/notebooks/app/send_real_alerts.ipynb index 07f3bb5..f5a4b50 100644 --- a/containers/notebooks/app/send_real_alerts.ipynb +++ b/containers/notebooks/app/send_real_alerts.ipynb @@ -23,7 +23,7 @@ "import pandas as pd\n", "from dotenv import load_dotenv\n", "import os\n", - "from api import get_token, get_camera_token\n", + "from api import get_service_token, get_camera_token\n", "from pyroclient import Client\n", "import glob\n", "from PIL import Image\n", @@ -45,11 +45,12 @@ "source": [ "API_URL = \"http://api:5050\"\n", "load_dotenv(\"../.env\")\n", - "SUPERADMIN_LOGIN = os.environ.get(\"SUPERADMIN_LOGIN\")\n", - "SUPERADMIN_PWD = os.environ.get(\"SUPERADMIN_PWD\")\n", + "KEYCLOAK_TOKEN_URL = os.environ.get(\"KEYCLOAK_TOKEN_URL\")\n", + "KEYCLOAK_SCRIPTS_CLIENT_ID = os.environ.get(\"KEYCLOAK_SCRIPTS_CLIENT_ID\")\n", + "KEYCLOAK_SCRIPTS_CLIENT_SECRET = os.environ.get(\"KEYCLOAK_SCRIPTS_CLIENT_SECRET\")\n", "\n", - "# Get access token\n", - "admin_access_token = get_token(API_URL, SUPERADMIN_LOGIN, SUPERADMIN_PWD)\n" + "# Get an admin access token from Keycloak (pyro-scripts service account)\n", + "admin_access_token = get_service_token(KEYCLOAK_TOKEN_URL, KEYCLOAK_SCRIPTS_CLIENT_ID, KEYCLOAK_SCRIPTS_CLIENT_SECRET)\n" ] }, { diff --git a/containers/notebooks/app/test poses.ipynb b/containers/notebooks/app/test poses.ipynb index c801a17..d48dd6a 100644 --- a/containers/notebooks/app/test poses.ipynb +++ b/containers/notebooks/app/test poses.ipynb @@ -9,18 +9,19 @@ "source": [ "from dotenv import load_dotenv\n", "import os\n", - "from api import get_token, get_camera_token\n", + "from api import get_service_token, get_camera_token\n", "from pyroclient import Client\n", "import requests\n", "\n", "\n", "API_URL = \"http://api:5050\"\n", "load_dotenv(\"../.env\")\n", - "SUPERADMIN_LOGIN = os.environ.get(\"SUPERADMIN_LOGIN\")\n", - "SUPERADMIN_PWD = os.environ.get(\"SUPERADMIN_PWD\")\n", + "KEYCLOAK_TOKEN_URL = os.environ.get(\"KEYCLOAK_TOKEN_URL\")\n", + "KEYCLOAK_SCRIPTS_CLIENT_ID = os.environ.get(\"KEYCLOAK_SCRIPTS_CLIENT_ID\")\n", + "KEYCLOAK_SCRIPTS_CLIENT_SECRET = os.environ.get(\"KEYCLOAK_SCRIPTS_CLIENT_SECRET\")\n", "\n", - "# Get access token\n", - "admin_access_token = get_token(API_URL, SUPERADMIN_LOGIN, SUPERADMIN_PWD)\n", + "# Get an admin access token from Keycloak (pyro-scripts service account)\n", + "admin_access_token = get_service_token(KEYCLOAK_TOKEN_URL, KEYCLOAK_SCRIPTS_CLIENT_ID, KEYCLOAK_SCRIPTS_CLIENT_SECRET)\n", "\n", "\n", "api_client = Client(admin_access_token, API_URL)" From 1fe9f9f98312cfc6b112f209f610952736400cc5 Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Wed, 28 Oct 2026 14:37:03 +0100 Subject: [PATCH 4/6] docs: document the Keycloak users, API client and service account --- README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d35daee..431af34 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ make run * **pyro-api**: Pyronear API (uvicorn) * **db**: PostgreSQL database * **rustfs**: S3-compatible storage (via RustFS) +* **keycloak**: identity provider for platform users and scripts (realm `pyronear`). The API image must + verify Keycloak tokens, which needs pyro-api's Keycloak support (build it with `make build-external` + until a release ships it). * **frontend**: Web app (React, [pyro-platform](https://github.com/pyronear/pyro-platform)) * **pyro-engine**: Engine service (requires cameras, optional) * **reolinkdev1 / reolinkdev2**: Fake Reolink cameras sending test images @@ -70,7 +73,7 @@ docker logs engine ### Partial runs -* Backend only (API, DB, S3): +* Backend only (API, DB, S3, Keycloak): ```bash make run-backend @@ -97,6 +100,11 @@ docker logs engine * Admin access currently does not display camera alerts, use user creds from `data/csv/users.csv` * Configuration (API URL, etc.) lives in `containers/frontend/app-config.js` * **Notebooks**: [http://localhost:8889](http://localhost:8889) +* **Keycloak admin console**: [http://localhost:9310/admin/](http://localhost:9310/admin/) + + * Login: `KEYCLOAK_USERNAME` / `KEYCLOAK_PASSWORD` (set in `.env`) + * The realm is imported from `containers/keycloak/realm/realm_pyronear.json` only when the container is + created. After changing that file, recreate it: `docker compose up -d --force-recreate keycloak` * **pgAdmin (db-ui)**: [http://localhost:8888/browser/](http://localhost:8888/browser/) * Login: `DB_UI_MAIL` / `DB_UI_PWD` (set in `.env`) @@ -115,10 +123,47 @@ docker logs engine ### Users Keycloak -| Username | Organization_id | Password | -|----------|-----------------|----------| -| test77 | 2 | test | -| test07 | 3 | test | +| Username | Organization_id | Realm role | Password | +|---------------|-----------------|------------|----------| +| test77 | 2 | operator | test | +| test07 | 3 | operator | test | +| test07-viewer | 3 | viewer | test | +| test-admin | 1 | admin | test | + +Realm roles map to API roles: `admin` → `admin`, `operator` → `agent`, `viewer` → `user`. +The organization comes from the `pyro-organization` user attribute (an API organization id). + +Access tokens for pyro-api carry: + +* `aud`: includes `pyro-api` (`pyro-api` client scope) +* `roles`: the user's realm roles (`pyro-api` client scope) +* `organization_id`: the `pyro-organization` attribute (`pyro-organization` client scope) + +### API client + +pyro-api checks every Keycloak token twice: locally (signature, expiry, issuer, audience), then with +Keycloak's introspection endpoint, so a revoked session or a disabled user is rejected on its next request. +It authenticates those calls as the confidential `pyro-api` client (secret `KEYCLOAK_API_CLIENT_SECRET`, +dev-only value `pyro-api-dev-secret`). + +### Scripts service account + +The init script and notebooks call the API with an admin token from the `pyro-scripts` client +(client credentials grant, realm role `admin`, organization 1), configured by `KEYCLOAK_TOKEN_URL`, +`KEYCLOAK_SCRIPTS_CLIENT_ID` and `KEYCLOAK_SCRIPTS_CLIENT_SECRET` in `.env`. The secret is a dev-only value +committed in the realm export. + +```python +from api import get_token +token = get_token(KEYCLOAK_TOKEN_URL, KEYCLOAK_SCRIPTS_CLIENT_ID, KEYCLOAK_SCRIPTS_CLIENT_SECRET) +``` + +Service-account tokens last 10 hours (`access.token.lifespan` on the `pyro-scripts` client, capped by the +realm's 10-hour session maximum) because the scripts fetch one token per run; user tokens keep the realm's +5 minutes. + +From the host, use `http://localhost:9310/realms/pyronear/protocol/openid-connect/token` as the token URL. +Keycloak issues the same `iss` (`http://localhost:9310/realms/pyronear`) either way (`KC_HOSTNAME`). ### Add more images to Reolink Dev From 11d0d169558d9f59e9669fbd478e8f99626f9850 Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Thu, 29 Oct 2026 20:24:00 +0100 Subject: [PATCH 5/6] docs: use get_service_token in the service account example --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 431af34..053529f 100644 --- a/README.md +++ b/README.md @@ -154,8 +154,8 @@ The init script and notebooks call the API with an admin token from the `pyro-sc committed in the realm export. ```python -from api import get_token -token = get_token(KEYCLOAK_TOKEN_URL, KEYCLOAK_SCRIPTS_CLIENT_ID, KEYCLOAK_SCRIPTS_CLIENT_SECRET) +from api import get_service_token +token = get_service_token(KEYCLOAK_TOKEN_URL, KEYCLOAK_SCRIPTS_CLIENT_ID, KEYCLOAK_SCRIPTS_CLIENT_SECRET) ``` Service-account tokens last 10 hours (`access.token.lifespan` on the `pyro-scripts` client, capped by the From 2b5c7f3fd97a3ced18fed44c3da2599bed957c6d Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Thu, 29 Oct 2026 20:24:00 +0100 Subject: [PATCH 6/6] style: fix pre-commit (end of files, black, ruff line length) --- .env.test | 2 +- containers/init_script/init_script.py | 5 ++++- containers/keycloak/realm/realm_pyronear.json | 2 +- .../themes/pyro-theme/login/theme.properties | 2 +- containers/notebooks/app/api.py | 13 ++++++++++--- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.env.test b/.env.test index 90c34d0..9096500 100644 --- a/.env.test +++ b/.env.test @@ -53,4 +53,4 @@ KEYCLOAK_TOKEN_URL=http://keycloak:9310/realms/pyronear/protocol/openid-connect/ KEYCLOAK_SCRIPTS_CLIENT_ID=pyro-scripts KEYCLOAK_SCRIPTS_CLIENT_SECRET=pyro-scripts-dev-secret # pyro-api client: the API introspects every Keycloak token with it -KEYCLOAK_API_CLIENT_SECRET=pyro-api-dev-secret \ No newline at end of file +KEYCLOAK_API_CLIENT_SECRET=pyro-api-dev-secret diff --git a/containers/init_script/init_script.py b/containers/init_script/init_script.py index b7d5ba0..e21ea64 100644 --- a/containers/init_script/init_script.py +++ b/containers/init_script/init_script.py @@ -37,8 +37,11 @@ credentials_path_etl = "data/credentials-wildfire.json" +admin_token = get_service_token( + keycloak_token_url, keycloak_client_id, keycloak_client_secret +) superuser_auth = { - "Authorization": f"Bearer {get_service_token(keycloak_token_url, keycloak_client_id, keycloak_client_secret)}", + "Authorization": f"Bearer {admin_token}", "Content-Type": "application/json", } diff --git a/containers/keycloak/realm/realm_pyronear.json b/containers/keycloak/realm/realm_pyronear.json index 6d07a43..1f3c074 100644 --- a/containers/keycloak/realm/realm_pyronear.json +++ b/containers/keycloak/realm/realm_pyronear.json @@ -2935,4 +2935,4 @@ "clientPolicies": { "policies": [] } -} \ No newline at end of file +} diff --git a/containers/keycloak/themes/pyro-theme/login/theme.properties b/containers/keycloak/themes/pyro-theme/login/theme.properties index 6a18317..bdeb284 100644 --- a/containers/keycloak/themes/pyro-theme/login/theme.properties +++ b/containers/keycloak/themes/pyro-theme/login/theme.properties @@ -2,4 +2,4 @@ parent=keycloak import=common/keycloak styles=css/login.css css/styles.css -# https://github.com/keycloak/keycloak/tree/main/themes/src/main/resources/theme/keycloak.v2/login \ No newline at end of file +# https://github.com/keycloak/keycloak/tree/main/themes/src/main/resources/theme/keycloak.v2/login diff --git a/containers/notebooks/app/api.py b/containers/notebooks/app/api.py index 9100a5c..ec69a79 100644 --- a/containers/notebooks/app/api.py +++ b/containers/notebooks/app/api.py @@ -12,10 +12,14 @@ def get_service_token(token_url: str, client_id: str, client_secret: str) -> str: - """Get an access token from Keycloak for a service account (client credentials grant)""" + """Get a Keycloak access token for a service account (client credentials)""" response = requests.post( token_url, - data={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret}, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, timeout=5, ) response.raise_for_status() @@ -23,7 +27,10 @@ def get_service_token(token_url: str, client_id: str, client_secret: str) -> str def get_token(API_URL, login: str, passwrd: str) -> str: - """Legacy: log in with API credentials. Only works while pyro-api accepts API-issued user tokens.""" + """Legacy: log in with API credentials. + + Only works while pyro-api accepts API-issued user tokens. + """ response = requests.post( urljoin(API_URL, "/api/v1/login/creds"), data={"username": login, "password": passwrd},