generated from coulomb/repo-seed
feat(local-identity): Stage 2 — Keycloak export & bootstrap integration (NK-WP-0002-T02)
export.py:
- split_fullname(): last-token strategy (Bernd Worsch → firstName/lastName)
- _deterministic_id(): uuid5(DNS, "local-identity.{realm}.{username}") for stable,
re-import-idempotent Keycloak IDs
- user_to_keycloak(): full Keycloak Admin REST API user representation;
production_identity mapping applied to username + realm; isolation attributes
(local_identity_environment, local_identity_generated) always present;
validate_keycloak_user() called on every conversion to catch schema drift
- bulk_export_body(): partial import body (ifResourceExists/realm/users)
cli.py: add `export` subcommand
- export <username> single user, prints Keycloak JSON
- export (no args) bulk; primary users only; stderr note on skipped test users
- export --include-test bulk; all users including generated
- --realm / --if-resource-exists flags
docs/LocalIdentity.md: add two new sections
- Keycloak import procedure: export → partialImport API → password reset → retire
- Isolation guarantee: attribute schema, Keycloak Condition authenticator config,
production_identity mapping walkthrough
tests/test_export.py: 34 new tests (88 total, all passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
175
local-identity/src/local_identity/export.py
Normal file
175
local-identity/src/local_identity/export.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Keycloak export: convert UserRecords to Keycloak Admin REST API representations.
|
||||
|
||||
Single-user export:
|
||||
user_to_keycloak(user, realm="net-kingdom") → dict
|
||||
|
||||
Bulk export (Keycloak partial import body):
|
||||
bulk_export_body(users, realm="net-kingdom") → dict
|
||||
POST /admin/realms/{realm}/partialImport
|
||||
|
||||
Deterministic IDs:
|
||||
UUIDs are generated with uuid5(DNS, "local-identity.{realm}.{username}") so
|
||||
repeated exports of the same user produce the same Keycloak ID. This makes
|
||||
re-imports idempotent when ifResourceExists="SKIP".
|
||||
|
||||
Isolation markers:
|
||||
All exported users carry:
|
||||
attributes.local_identity_environment = ["local"]
|
||||
Generated test users additionally carry:
|
||||
attributes.local_identity_generated = ["true"]
|
||||
Configure Keycloak to deny authentication for users with
|
||||
local_identity_environment == "local" as a defence-in-depth measure.
|
||||
See docs/LocalIdentity.md — "Isolation guarantee" section.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from .user import UserRecord
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Name splitting #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def split_fullname(fullname: str) -> tuple[str, str]:
|
||||
"""
|
||||
Split a display name into (firstName, lastName).
|
||||
Strategy: the last whitespace-separated token is lastName; everything
|
||||
before it is firstName. A single-word name maps to (name, '').
|
||||
|
||||
Examples:
|
||||
"Bernd Worsch" → ("Bernd", "Worsch")
|
||||
"Jean Claude Damme" → ("Jean Claude", "Damme")
|
||||
"Madonna" → ("Madonna", "")
|
||||
"""
|
||||
parts = fullname.strip().split()
|
||||
if len(parts) >= 2:
|
||||
return " ".join(parts[:-1]), parts[-1]
|
||||
return fullname.strip(), ""
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Deterministic ID #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _deterministic_id(realm: str, username: str) -> str:
|
||||
"""
|
||||
Stable UUID5 for a (realm, username) pair.
|
||||
Using NAMESPACE_DNS keeps it domain-agnostic and collision-resistant.
|
||||
"""
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_DNS, f"local-identity.{realm}.{username}"))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Schema validation #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
_REQUIRED_FIELDS: dict[str, type] = {
|
||||
"username": str,
|
||||
"firstName": str,
|
||||
"lastName": str,
|
||||
"email": str,
|
||||
"enabled": bool,
|
||||
"emailVerified": bool,
|
||||
"attributes": dict,
|
||||
"credentials": list,
|
||||
}
|
||||
|
||||
|
||||
def validate_keycloak_user(d: dict) -> None:
|
||||
"""
|
||||
Minimal structural validation against the expected Keycloak user schema.
|
||||
Raises ValueError listing every missing or wrongly-typed field, so that
|
||||
changes to user_to_keycloak() are caught before they reach a live realm.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
for field, expected in _REQUIRED_FIELDS.items():
|
||||
if field not in d:
|
||||
errors.append(f"missing required field '{field}'")
|
||||
elif not isinstance(d[field], expected):
|
||||
actual = type(d[field]).__name__
|
||||
errors.append(
|
||||
f"field '{field}': expected {expected.__name__}, got {actual}"
|
||||
)
|
||||
if errors:
|
||||
raise ValueError(
|
||||
"Keycloak user schema validation failed:\n"
|
||||
+ "\n".join(f" - {e}" for e in errors)
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Conversion #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def user_to_keycloak(user: UserRecord, realm: str = "net-kingdom") -> dict:
|
||||
"""
|
||||
Convert a UserRecord to a Keycloak Admin REST API user representation.
|
||||
|
||||
production_identity mapping:
|
||||
If the user carries a production_identity block, the Keycloak username
|
||||
and realm are taken from it. This allows a local username of 'tegwick'
|
||||
to map to a different production username/realm before import.
|
||||
"""
|
||||
first_name, last_name = split_fullname(user.fullname)
|
||||
|
||||
kc_username = user.username
|
||||
kc_realm = realm
|
||||
if user.production_identity:
|
||||
kc_username = user.production_identity.username
|
||||
if user.production_identity.realm:
|
||||
kc_realm = user.production_identity.realm
|
||||
|
||||
attrs: dict[str, list[str]] = {
|
||||
"local_identity_environment": [user.environment],
|
||||
}
|
||||
if user.generated:
|
||||
attrs["local_identity_generated"] = ["true"]
|
||||
|
||||
d: dict = {
|
||||
"id": _deterministic_id(kc_realm, kc_username),
|
||||
"username": kc_username,
|
||||
"firstName": first_name,
|
||||
"lastName": last_name,
|
||||
"email": user.email,
|
||||
"enabled": True,
|
||||
"emailVerified": False,
|
||||
"attributes": attrs,
|
||||
"credentials": [],
|
||||
"requiredActions": [],
|
||||
"groups": [],
|
||||
"realmRoles": [],
|
||||
"clientRoles": {},
|
||||
}
|
||||
|
||||
validate_keycloak_user(d)
|
||||
return d
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Bulk export #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def bulk_export_body(
|
||||
users: Sequence[UserRecord],
|
||||
realm: str = "net-kingdom",
|
||||
if_resource_exists: str = "SKIP",
|
||||
) -> dict:
|
||||
"""
|
||||
Produce a Keycloak partial import request body.
|
||||
|
||||
POST /admin/realms/{realm}/partialImport
|
||||
Content-Type: application/json
|
||||
|
||||
ifResourceExists values: SKIP | OVERWRITE | FAIL
|
||||
SKIP is the safe default — existing users are left unchanged.
|
||||
"""
|
||||
return {
|
||||
"ifResourceExists": if_resource_exists,
|
||||
"realm": realm,
|
||||
"users": [user_to_keycloak(u, realm=realm) for u in users],
|
||||
}
|
||||
Reference in New Issue
Block a user