460 lines
18 KiB
Python
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()
|