Files
next-tjwater-server/scripts/provision_operational_worker_identity.py
T

460 lines
18 KiB
Python

#!/usr/bin/env python3
"""Provision the least-privileged OAuth identity used by operational workers.
The Keycloak administrator password is read with a hidden prompt or from the
process-only KEYCLOAK_ADMIN_PASSWORD environment variable. It is never written
to disk. Client secrets are written only to the ignored server .env file.
"""
from __future__ import annotations
import argparse
import asyncio
import base64
import getpass
import json
import os
from pathlib import Path
import sys
from urllib.parse import urlparse
from uuid import UUID
import httpx
from dotenv import dotenv_values, set_key
from sqlalchemy import select
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
if str(REPOSITORY_ROOT) not in sys.path:
sys.path.insert(0, str(REPOSITORY_ROOT))
from app.auth.permissions import permissions_for_context
from app.auth.project_dependencies import ProjectContext
from app.infra.db.metadb import models
from app.infra.db.metadb.database import SessionLocal, close_metadata_engine
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
class ProvisionError(RuntimeError):
pass
def _request(client: httpx.Client, method: str, url: str, **kwargs) -> httpx.Response:
try:
response = client.request(method, url, **kwargs)
except httpx.HTTPError as exc:
raise ProvisionError(f"identity provider request failed: {method} {url}") from exc
if response.status_code >= 400:
raise ProvisionError(
f"identity provider rejected request: {method} {url} status={response.status_code}"
)
return response
def _jwt_payload(token: str) -> dict:
try:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
value = json.loads(base64.urlsafe_b64decode(payload))
except (IndexError, ValueError, json.JSONDecodeError) as exc:
raise ProvisionError("identity provider returned an invalid access token") from exc
if not isinstance(value, dict):
raise ProvisionError("identity provider returned an invalid access token payload")
return value
def _audiences(payload: dict) -> set[str]:
audience = payload.get("aud", [])
if isinstance(audience, str):
return {audience}
if isinstance(audience, list):
return {item for item in audience if isinstance(item, str)}
return set()
class KeycloakProvisioner:
def __init__(self, base_url: str, realm: str, admin_username: str, admin_password: str):
self.base_url = base_url.rstrip("/")
self.realm = realm
self.client = httpx.Client(timeout=15, follow_redirects=False, trust_env=False)
token_response = _request(
self.client,
"POST",
f"{self.base_url}/realms/master/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": "admin-cli",
"username": admin_username,
"password": admin_password,
},
)
token = token_response.json().get("access_token")
if not isinstance(token, str) or not token:
raise ProvisionError("administrator authentication returned no access token")
self.client.headers["Authorization"] = f"Bearer {token}"
self.admin_base = f"{self.base_url}/admin/realms/{realm}"
def close(self) -> None:
self.client.close()
def ensure_client(self, client_id: str) -> tuple[str, bool]:
matches = _request(
self.client,
"GET",
f"{self.admin_base}/clients",
params={"clientId": client_id},
).json()
exact = [item for item in matches if item.get("clientId") == client_id]
if len(exact) > 1:
raise ProvisionError("multiple Keycloak clients use the requested client id")
created = not exact
if created:
_request(
self.client,
"POST",
f"{self.admin_base}/clients",
json={
"clientId": client_id,
"name": "TJWater operational worker",
"description": "Dedicated backend worker identity; no interactive login",
"enabled": True,
"protocol": "openid-connect",
"publicClient": False,
"bearerOnly": False,
"serviceAccountsEnabled": True,
"standardFlowEnabled": False,
"implicitFlowEnabled": False,
"directAccessGrantsEnabled": False,
"fullScopeAllowed": False,
"clientAuthenticatorType": "client-secret",
},
)
matches = _request(
self.client,
"GET",
f"{self.admin_base}/clients",
params={"clientId": client_id},
).json()
exact = [item for item in matches if item.get("clientId") == client_id]
if len(exact) != 1 or not isinstance(exact[0].get("id"), str):
raise ProvisionError("could not resolve the dedicated Keycloak client")
internal_id = exact[0]["id"]
representation = _request(
self.client, "GET", f"{self.admin_base}/clients/{internal_id}"
).json()
desired = {
"enabled": True,
"publicClient": False,
"bearerOnly": False,
"serviceAccountsEnabled": True,
"standardFlowEnabled": False,
"implicitFlowEnabled": False,
"directAccessGrantsEnabled": False,
"fullScopeAllowed": False,
"clientAuthenticatorType": "client-secret",
}
if any(representation.get(key) != value for key, value in desired.items()):
representation.update(desired)
_request(
self.client,
"PUT",
f"{self.admin_base}/clients/{internal_id}",
json=representation,
)
return internal_id, created
def client_secret(self, internal_id: str) -> str:
value = _request(
self.client,
"GET",
f"{self.admin_base}/clients/{internal_id}/client-secret",
).json().get("value")
if not isinstance(value, str) or not value:
raise ProvisionError("dedicated client has no usable secret")
return value
def service_account_subject(self, internal_id: str) -> UUID:
value = _request(
self.client,
"GET",
f"{self.admin_base}/clients/{internal_id}/service-account-user",
).json().get("id")
try:
return UUID(str(value))
except ValueError as exc:
raise ProvisionError("service account subject is not a UUID") from exc
def ensure_audience_mapper(self, internal_id: str, audience: str) -> bool:
mappers = _request(
self.client,
"GET",
f"{self.admin_base}/clients/{internal_id}/protocol-mappers/models",
).json()
for mapper in mappers:
config = mapper.get("config") or {}
if (
mapper.get("protocolMapper") == "oidc-audience-mapper"
and config.get("included.client.audience") == audience
and config.get("access.token.claim") == "true"
):
return False
_request(
self.client,
"POST",
f"{self.admin_base}/clients/{internal_id}/protocol-mappers/models",
json={
"name": f"audience-{audience}",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": False,
"config": {
"included.client.audience": audience,
"id.token.claim": "false",
"access.token.claim": "true",
"lightweight.claim": "false",
},
},
)
return True
def service_token(self, client_id: str, client_secret: str) -> tuple[str, dict]:
response = _request(
self.client,
"POST",
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token",
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
)
token = response.json().get("access_token")
if not isinstance(token, str) or not token:
raise ProvisionError("service account authentication returned no access token")
return token, _jwt_payload(token)
async def _bind_product_identity(
subject: UUID,
*,
username: str,
email: str,
project_id: UUID,
project_code: str,
) -> tuple[UUID, str, bool, bool]:
async with SessionLocal() as session:
repo = MetadataRepository(session)
project = await repo.get_project_by_id(project_id)
if project is None or project.code != project_code or project.status != "active":
raise ProvisionError("target product project is missing, mismatched, or inactive")
user = await repo.get_user_by_keycloak_id(subject)
created_user = user is None
if user is None:
username_conflict = await repo.get_user_by_username(username)
email_result = await session.execute(select(models.User).where(models.User.email == email))
if username_conflict is not None or email_result.scalar_one_or_none() is not None:
raise ProvisionError("product user name or email is already bound to another identity")
user = await repo.upsert_user_from_keycloak(
keycloak_id=subject,
username=username,
email=email,
role="service",
is_active=True,
)
elif (
user.username == username
and user.role == "user"
and user.is_active
and not user.is_superuser
):
# Forward migration for identities provisioned before the dedicated
# service role existed.
user = await repo.upsert_user_from_keycloak(
keycloak_id=subject,
username=username,
email=email,
role="service",
is_active=True,
)
elif (
user.username != username
or user.role != "service"
or not user.is_active
or user.is_superuser
):
raise ProvisionError("existing product user has incompatible identity or privileges")
membership = await repo.get_project_membership(project_id, user.id)
created_membership = membership is None
if membership is None:
membership = await repo.add_project_member(project_id, user.id, "member")
elif membership.project_role != "member":
raise ProvisionError("existing project membership has an incompatible role")
return user.id, membership.project_role, created_user, created_membership
def _update_worker_environment(
env_path: Path,
*,
token_url: str,
client_id: str,
client_secret: str,
project_id: UUID,
) -> None:
current = dotenv_values(env_path)
if current.get("OPERATIONAL_WORKER_BEARER"):
raise ProvisionError("a fixed worker bearer already exists; refusing to overwrite it")
values = {
"OPERATIONAL_WORKER_TOKEN_URL": token_url,
"OPERATIONAL_WORKER_CLIENT_ID": client_id,
"OPERATIONAL_WORKER_CLIENT_SECRET": client_secret,
"OPERATIONAL_WORKER_PROJECT_ID": str(project_id),
"OPERATIONAL_WORKER_SERVER_URL": "http://127.0.0.1:8000",
"OPERATIONAL_WORKER_AGENT_URL": "http://127.0.0.1:18787",
"OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS": "300",
"OPERATIONAL_ACTION_PERIPHERAL_URL": "http://127.0.0.1:8011",
"OPERATIONAL_DATA_PERIPHERAL_URL": "http://127.0.0.1:8011",
"OPERATIONAL_DATA_SERVER_URL": "http://127.0.0.1:8000",
}
for key, value in values.items():
set_key(str(env_path), key, value, quote_mode="always")
env_path.chmod(0o600)
def _verify_product_context(token: str, project_id: UUID) -> dict:
try:
response = httpx.get(
"http://127.0.0.1:8000/api/v1/agent-auth-context",
headers={
"Authorization": f"Bearer {token}",
"X-Project-Id": str(project_id),
},
timeout=10,
follow_redirects=False,
trust_env=False,
)
except httpx.HTTPError as exc:
raise ProvisionError("product identity verification request failed") from exc
if response.status_code != 200:
raise ProvisionError(
f"product identity verification was rejected with status={response.status_code}"
)
value = response.json()
if not isinstance(value, dict):
raise ProvisionError("product identity verification returned an invalid response")
return value
async def provision(args: argparse.Namespace, admin_password: str) -> dict:
env_path = Path(args.env_file).resolve()
env_values = dotenv_values(env_path)
audience = env_values.get("KEYCLOAK_AUDIENCE") or ""
if audience != "account":
raise ProvisionError("backend audience is not the expected account audience")
keycloak = KeycloakProvisioner(
args.keycloak_url, args.realm, args.admin_username, admin_password
)
try:
internal_id, created_client = keycloak.ensure_client(args.client_id)
secret = keycloak.client_secret(internal_id)
subject = keycloak.service_account_subject(internal_id)
token, payload = keycloak.service_token(args.client_id, secret)
mapper_created = False
if audience not in _audiences(payload):
mapper_created = keycloak.ensure_audience_mapper(internal_id, audience)
token, payload = keycloak.service_token(args.client_id, secret)
if audience not in _audiences(payload):
raise ProvisionError("service token does not contain the backend audience")
if payload.get("sub") != str(subject) or not payload.get("preferred_username"):
raise ProvisionError("service token identity claims do not match the service account")
realm_management = (payload.get("resource_access") or {}).get("realm-management", {})
if realm_management.get("roles"):
raise ProvisionError("service account unexpectedly has realm-management roles")
product_user_id, project_role, created_user, created_membership = (
await _bind_product_identity(
subject,
username=f"service-account-{args.client_id}",
email=f"{args.client_id}@service.local",
project_id=UUID(args.project_id),
project_code=args.project_code,
)
)
context = ProjectContext(
project_id=UUID(args.project_id),
project_code=args.project_code,
user_id=product_user_id,
project_role=project_role,
system_role="service",
is_superuser=False,
)
expected_permissions = sorted(permissions_for_context(context))
_update_worker_environment(
env_path,
token_url=(
f"{args.keycloak_url.rstrip('/')}/realms/{args.realm}"
"/protocol/openid-connect/token"
),
client_id=args.client_id,
client_secret=secret,
project_id=UUID(args.project_id),
)
verified = _verify_product_context(token, UUID(args.project_id))
if (
verified.get("project_id") != args.project_id
or verified.get("network") != args.project_code
or verified.get("project_role") != "member"
or verified.get("role") != "service"
or verified.get("is_superuser") is not False
or verified.get("permissions") != expected_permissions
):
raise ProvisionError("product identity verification returned unexpected authorization")
return {
"client_created": created_client,
"audience_mapper_created": mapper_created,
"product_user_created": created_user,
"membership_created": created_membership,
"project_code": verified["network"],
"project_role": verified["project_role"],
"system_role": verified["role"],
"permissions": verified["permissions"],
}
finally:
keycloak.close()
await close_metadata_engine()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--keycloak-url", default="http://192.168.1.114:8088")
parser.add_argument(
"--allow-insecure-http",
action="store_true",
help="allow cleartext Keycloak credentials for an explicitly trusted development network",
)
parser.add_argument("--realm", default="tjwater")
parser.add_argument("--admin-username", default="admin")
parser.add_argument("--client-id", default="tjwater-operational-worker")
parser.add_argument("--project-id", required=True)
parser.add_argument("--project-code", default="tjwater_v2")
parser.add_argument(
"--env-file", default=str(Path(__file__).resolve().parents[1] / ".env")
)
args = parser.parse_args()
keycloak_url = urlparse(args.keycloak_url)
if keycloak_url.scheme != "https" and not args.allow_insecure_http:
raise SystemExit(
"Refusing to send administrator and client credentials over cleartext HTTP; "
"use HTTPS or explicitly pass --allow-insecure-http for development"
)
password = os.environ.get("KEYCLOAK_ADMIN_PASSWORD") or getpass.getpass(
"Keycloak admin password: "
)
if not password:
raise SystemExit("administrator password is required")
try:
result = asyncio.run(provision(args, password))
except ProvisionError as exc:
raise SystemExit(f"provisioning failed: {exc}") from None
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
if __name__ == "__main__":
main()