diff --git a/.env.test b/.env.test index ddb0c92..9096500 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 diff --git a/README.md b/README.md index d35daee..053529f 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_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 +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 diff --git a/containers/init_script/init_script.py b/containers/init_script/init_script.py index 94f0594..e21ea64 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,19 @@ # 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" +admin_token = get_service_token( + keycloak_token_url, keycloak_client_id, keycloak_client_secret +) superuser_auth = { - "Authorization": f"Bearer {get_token(api_url, superuser_login, superuser_pwd)}", + "Authorization": f"Bearer {admin_token}", "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/keycloak/realm/realm_pyronear.json b/containers/keycloak/realm/realm_pyronear.json index 946ab48..1f3c074 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", @@ -2801,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 e72b62f..ec69a79 100644 --- a/containers/notebooks/app/api.py +++ b/containers/notebooks/app/api.py @@ -8,11 +8,29 @@ 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 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, + }, + 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)" 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: