feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user