keycloak: include names in provisioning

This commit is contained in:
Brad Stein 2026-01-21 19:49:05 -03:00
parent e4a6cbc104
commit a2ae62bb23
8 changed files with 49 additions and 14 deletions

View File

@ -384,6 +384,8 @@ def list_access_requests(ctx: AuthContext = Depends(_require_auth)) -> JSONRespo
"id": row.get("request_code"), "id": row.get("request_code"),
"username": row.get("username"), "username": row.get("username"),
"email": row.get("contact_email") or "", "email": row.get("contact_email") or "",
"first_name": row.get("first_name") or "",
"last_name": row.get("last_name") or "",
"request_code": row.get("request_code"), "request_code": row.get("request_code"),
"created_at": created_at.isoformat() if isinstance(created_at, datetime) else "", "created_at": created_at.isoformat() if isinstance(created_at, datetime) else "",
"note": row.get("note") or "", "note": row.get("note") or "",

View File

@ -49,4 +49,6 @@ ARIADNE_ACCESS_REQUEST_ALTER = [
"ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS approval_flags TEXT[]", "ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS approval_flags TEXT[]",
"ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS approval_note TEXT", "ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS approval_note TEXT",
"ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS denial_note TEXT", "ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS denial_note TEXT",
"ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS first_name TEXT",
"ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS last_name TEXT",
] ]

View File

@ -25,6 +25,8 @@ REQUIRED_TASKS = (
class AccessRequest: class AccessRequest:
request_code: str request_code: str
username: str username: str
first_name: str
last_name: str
contact_email: str contact_email: str
status: str status: str
email_verified_at: datetime | None email_verified_at: datetime | None
@ -113,8 +115,8 @@ class Storage:
row = self._portal_db.fetchone( row = self._portal_db.fetchone(
""" """
SELECT request_code, username, contact_email, status, email_verified_at, SELECT request_code, username, contact_email, status, email_verified_at,
initial_password, initial_password_revealed_at, provision_attempted_at, first_name, last_name, initial_password, initial_password_revealed_at,
approval_flags, approval_note, denial_note provision_attempted_at, approval_flags, approval_note, denial_note
FROM access_requests FROM access_requests
WHERE request_code = %s WHERE request_code = %s
""", """,
@ -128,8 +130,8 @@ class Storage:
row = self._portal_db.fetchone( row = self._portal_db.fetchone(
""" """
SELECT request_code, username, contact_email, status, email_verified_at, SELECT request_code, username, contact_email, status, email_verified_at,
initial_password, initial_password_revealed_at, provision_attempted_at, first_name, last_name, initial_password, initial_password_revealed_at,
approval_flags, approval_note, denial_note provision_attempted_at, approval_flags, approval_note, denial_note
FROM access_requests FROM access_requests
WHERE username = %s WHERE username = %s
ORDER BY created_at DESC ORDER BY created_at DESC
@ -144,7 +146,7 @@ class Storage:
def list_pending_requests(self) -> list[dict[str, Any]]: def list_pending_requests(self) -> list[dict[str, Any]]:
return self._portal_db.fetchall( return self._portal_db.fetchall(
""" """
SELECT request_code, username, contact_email, note, status, created_at SELECT request_code, username, contact_email, first_name, last_name, note, status, created_at
FROM access_requests FROM access_requests
WHERE status = 'pending' WHERE status = 'pending'
ORDER BY created_at ASC ORDER BY created_at ASC
@ -156,8 +158,8 @@ class Storage:
rows = self._portal_db.fetchall( rows = self._portal_db.fetchall(
""" """
SELECT request_code, username, contact_email, status, email_verified_at, SELECT request_code, username, contact_email, status, email_verified_at,
initial_password, initial_password_revealed_at, provision_attempted_at, first_name, last_name, initial_password, initial_password_revealed_at,
approval_flags, approval_note, denial_note provision_attempted_at, approval_flags, approval_note, denial_note
FROM access_requests FROM access_requests
WHERE status IN ('approved', 'accounts_building') WHERE status IN ('approved', 'accounts_building')
ORDER BY created_at ASC ORDER BY created_at ASC
@ -351,6 +353,8 @@ class Storage:
return AccessRequest( return AccessRequest(
request_code=str(row.get("request_code") or ""), request_code=str(row.get("request_code") or ""),
username=str(row.get("username") or ""), username=str(row.get("username") or ""),
first_name=str(row.get("first_name") or ""),
last_name=str(row.get("last_name") or ""),
contact_email=str(row.get("contact_email") or ""), contact_email=str(row.get("contact_email") or ""),
status=str(row.get("status") or ""), status=str(row.get("status") or ""),
email_verified_at=row.get("email_verified_at"), email_verified_at=row.get("email_verified_at"),

View File

@ -44,6 +44,8 @@ class ProvisionOutcome:
class RequestContext: class RequestContext:
request_code: str request_code: str
username: str username: str
first_name: str
last_name: str
contact_email: str contact_email: str
email_verified_at: datetime | None email_verified_at: datetime | None
status: str status: str
@ -123,6 +125,8 @@ class ProvisioningManager:
row = conn.execute( row = conn.execute(
""" """
SELECT username, SELECT username,
first_name,
last_name,
contact_email, contact_email,
email_verified_at, email_verified_at,
status, status,
@ -142,6 +146,8 @@ class ProvisioningManager:
return RequestContext( return RequestContext(
request_code=request_code, request_code=request_code,
username=username, username=username,
first_name=str(row.get("first_name") or ""),
last_name=str(row.get("last_name") or ""),
contact_email=str(row.get("contact_email") or ""), contact_email=str(row.get("contact_email") or ""),
email_verified_at=row.get("email_verified_at"), email_verified_at=row.get("email_verified_at"),
status=str(row.get("status") or ""), status=str(row.get("status") or ""),
@ -450,8 +456,15 @@ class ProvisioningManager:
if existing_email_user and (existing_email_user.get("username") or "") != username: if existing_email_user and (existing_email_user.get("username") or "") != username:
raise RuntimeError("email is already associated with an existing Atlas account") raise RuntimeError("email is already associated with an existing Atlas account")
def _new_user_payload(self, username: str, email: str, mailu_email: str) -> dict[str, Any]: def _new_user_payload(
return { self,
username: str,
email: str,
mailu_email: str,
first_name: str,
last_name: str,
) -> dict[str, Any]:
payload = {
"username": username, "username": username,
"enabled": True, "enabled": True,
"email": email, "email": email,
@ -462,6 +475,13 @@ class ProvisioningManager:
MAILU_ENABLED_ATTR: ["true"], MAILU_ENABLED_ATTR: ["true"],
}, },
} }
if first_name:
payload["firstName"] = first_name
if last_name:
payload["lastName"] = last_name
else:
payload["lastName"] = username
return payload
def _create_or_fetch_user(self, ctx: RequestContext) -> dict[str, Any]: def _create_or_fetch_user(self, ctx: RequestContext) -> dict[str, Any]:
user = keycloak_admin.find_user(ctx.username) user = keycloak_admin.find_user(ctx.username)
@ -469,7 +489,7 @@ class ProvisioningManager:
return user return user
email = self._require_verified_email(ctx) email = self._require_verified_email(ctx)
self._ensure_email_unused(email, ctx.username) self._ensure_email_unused(email, ctx.username)
payload = self._new_user_payload(ctx.username, email, ctx.mailu_email) payload = self._new_user_payload(ctx.username, email, ctx.mailu_email, ctx.first_name, ctx.last_name)
try: try:
created_id = keycloak_admin.create_user(payload) created_id = keycloak_admin.create_user(payload)
return keycloak_admin.get_user(created_id) return keycloak_admin.get_user(created_id)

View File

@ -23,10 +23,9 @@ class ProfileSyncSummary:
def _profile_complete(user: dict[str, Any]) -> bool: def _profile_complete(user: dict[str, Any]) -> bool:
email = user.get("email") if isinstance(user.get("email"), str) else "" email = user.get("email") if isinstance(user.get("email"), str) else ""
first_name = user.get("firstName") if isinstance(user.get("firstName"), str) else ""
last_name = user.get("lastName") if isinstance(user.get("lastName"), str) else "" last_name = user.get("lastName") if isinstance(user.get("lastName"), str) else ""
email_verified = bool(user.get("emailVerified")) email_verified = bool(user.get("emailVerified"))
return bool(email.strip() and first_name.strip() and last_name.strip() and email_verified) return bool(email.strip() and last_name.strip() and email_verified)
def run_profile_sync() -> ProfileSyncSummary: def run_profile_sync() -> ProfileSyncSummary:

View File

@ -15,7 +15,7 @@ def test_profile_sync_removes_required_actions(monkeypatch) -> None:
"enabled": True, "enabled": True,
"email": "alice@example.com", "email": "alice@example.com",
"emailVerified": True, "emailVerified": True,
"firstName": "Alice", "firstName": "",
"lastName": "Atlas", "lastName": "Atlas",
"requiredActions": ["UPDATE_PROFILE", "VERIFY_EMAIL"], "requiredActions": ["UPDATE_PROFILE", "VERIFY_EMAIL"],
} }
@ -44,7 +44,7 @@ def test_profile_sync_skips_incomplete(monkeypatch) -> None:
"enabled": True, "enabled": True,
"email": "bob@example.com", "email": "bob@example.com",
"emailVerified": True, "emailVerified": True,
"firstName": "", "firstName": "Bob",
"lastName": "", "lastName": "",
"requiredActions": ["UPDATE_PROFILE"], "requiredActions": ["UPDATE_PROFILE"],
} }

View File

@ -209,6 +209,8 @@ def test_provisioning_creates_user_and_password(monkeypatch) -> None:
row = { row = {
"username": "alice", "username": "alice",
"first_name": "Alice",
"last_name": "Atlas",
"contact_email": "alice@example.com", "contact_email": "alice@example.com",
"email_verified_at": datetime.now(timezone.utc), "email_verified_at": datetime.now(timezone.utc),
"status": "approved", "status": "approved",
@ -225,6 +227,8 @@ def test_provisioning_creates_user_and_password(monkeypatch) -> None:
assert outcome.status == "awaiting_onboarding" assert outcome.status == "awaiting_onboarding"
assert admin.created_payload is not None assert admin.created_payload is not None
assert admin.created_payload.get("firstName") == "Alice"
assert admin.created_payload.get("lastName") == "Atlas"
assert admin.reset_calls assert admin.reset_calls

View File

@ -37,6 +37,8 @@ def test_row_to_request_flags() -> None:
row = { row = {
"request_code": "abc", "request_code": "abc",
"username": "alice", "username": "alice",
"first_name": "Alice",
"last_name": "Atlas",
"contact_email": "a@example.com", "contact_email": "a@example.com",
"status": "pending", "status": "pending",
"email_verified_at": None, "email_verified_at": None,
@ -57,6 +59,8 @@ def test_access_requests_use_portal_db() -> None:
portal_row = { portal_row = {
"request_code": "req", "request_code": "req",
"username": "alice", "username": "alice",
"first_name": "Alice",
"last_name": "Atlas",
"contact_email": "a@example.com", "contact_email": "a@example.com",
"status": "pending", "status": "pending",
"email_verified_at": None, "email_verified_at": None,