perf(web-app-keycloak): mark user emails verified in one REST call

The emailVerified update looped over every non-reserved user, uploaded a
shell script per user and ran kcadm with a JVM login each time, and it
always reported changed: 7 users took 23.8 s on echoserver.

email_verified.py uses the shared keycloak_admin client through
tasks/utils/rest_script.yml: it looks each user up by exact username,
skips users already verified, and writes emailVerified with a GET plus a
full-representation PUT, as kcadm update did. The task reports changed
only when a user was updated, and a failed lookup now fails the task
instead of being treated as a missing user. The old lookup piped
kcadm -q username=X into head -n1, which could have hit a longer username
sharing the prefix.

Evidence (local compose, slot 104): summary updated=0 unchanged=8
missing=0 failed=0 in 0.60 s, task ok, staging dir removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-24 19:39:53 +02:00
1 parent 9301220d05
commit 49aa920034
4 files changed
+228 -83

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Mark the email address of every listed Keycloak user verified over the admin REST API.
Env:
KC_URL: Keycloak base URL reachable from the network namespace the script runs in.
KC_PID: host PID of the Keycloak container, whose environment holds the admin credentials.
KC_REALM: realm holding the users.
USERNAMES: space-separated usernames to mark verified.
Output: one ``<outcome> <username>`` line per user, then one ``summary`` line.
Exit: 0 every user is verified or absent from the realm, 1 otherwise.
"""
from __future__ import annotations
import os
import sys
import urllib.parse
from pathlib import Path
from keycloak_admin import Keycloak, KeycloakError, container_credentials
OUTCOMES = ("updated", "unchanged", "missing", "failed")
def find_user(kc: Keycloak, username: str) -> dict | None:
"""Return the user whose username equals ``username``, or None.
Args:
kc: client.
username: the username, compared case-insensitively as Keycloak stores it lowercased.
"""
query = urllib.parse.urlencode({"username": username, "exact": "true"})
found, _ = kc.call("GET", f"users?{query}")
wanted = username.lower()
return next((user for user in found if user["username"].lower() == wanted), None)
def verify(kc: Keycloak, username: str) -> str:
"""Return ``updated``, ``unchanged`` or ``missing`` for a user.
Args:
kc: client.
username: the user whose email address gets marked verified.
"""
user = find_user(kc, username)
if user is None:
return "missing"
if user.get("emailVerified") is True:
return "unchanged"
current, _ = kc.call("GET", f"users/{user['id']}")
kc.call("PUT", f"users/{user['id']}", {**current, "emailVerified": True})
return "updated"
def main() -> int:
env = os.environ
try:
kc = Keycloak(
env["KC_URL"],
env["KC_REALM"],
*container_credentials(Path("/proc") / env["KC_PID"] / "environ"),
)
usernames = env["USERNAMES"].split()
except (OSError, KeyError, ValueError) as error:
print(f"setup-failed {error}")
return 1
outcomes = []
for username in usernames:
try:
outcome = verify(kc, username)
except (KeycloakError, OSError, KeyError, ValueError) as error:
print(f"{username}: {error}", file=sys.stderr)
outcome = "failed"
print(f"{outcome} {username}")
outcomes.append(outcome)
print("summary " + " ".join(f"{key}={outcomes.count(key)}" for key in OUTCOMES))
return 1 if "failed" in outcomes else 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,64 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
###############################################################################
# Set emailVerified for a Keycloak user (robust against noisy kcadm output)
#
# Runs on the host and executes kcadm inside the Keycloak container.
#
# REQUIRED environment variables:
# KEYCLOAK_EXEC_CONTAINER e.g. "container exec -i keycloak"
# KEYCLOAK_KCADM e.g. "/opt/keycloak/bin/kcadm.sh"
# KEYCLOAK_REALM Realm name
# KEYCLOAK_USERNAME Username to update
#
# OPTIONAL:
# KEYCLOAK_EMAIL_VERIFIED true|false (default: true)
###############################################################################
: "${KEYCLOAK_EXEC_CONTAINER:?missing KEYCLOAK_EXEC_CONTAINER}"
: "${KEYCLOAK_KCADM:?missing KEYCLOAK_KCADM}"
: "${KEYCLOAK_REALM:?missing KEYCLOAK_REALM}"
: "${KEYCLOAK_USERNAME:?missing KEYCLOAK_USERNAME}"
KEYCLOAK_EMAIL_VERIFIED="${KEYCLOAK_EMAIL_VERIFIED:-true}"
${KEYCLOAK_EXEC_CONTAINER} sh -lc "
set -euo pipefail
USERNAME=\"${KEYCLOAK_USERNAME}\"
REALM=\"${KEYCLOAK_REALM}\"
VERIFIED=\"${KEYCLOAK_EMAIL_VERIFIED}\"
RAW=\"\$(${KEYCLOAK_KCADM} get users -r \"\$REALM\" -q username=\"\$USERNAME\" --fields id --format csv --noquotes 2>&1 || true)\"
USER_ID=\"\$(printf '%s\n' \"\$RAW\" \
| grep -Eio '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' \
| head -n1 || true)\"
if [ -z \"\${USER_ID:-}\" ]; then
echo \"[keycloak][emailVerified] user not found: \$USERNAME\" >&2
exit 0
fi
CURRENT_RAW=\"\$(${KEYCLOAK_KCADM} get users/\$USER_ID -r \"\$REALM\" 2>&1 || true)\"
CURRENT_LINE=\"\$(printf '%s\n' \"\$CURRENT_RAW\" \
| tr -d '\r' \
| grep -Eo '\"emailVerified\"[[:space:]]*:[[:space:]]*(true|false)' \
| head -n1 || true)\"
CURRENT=\"\$(printf '%s' \"\$CURRENT_LINE\" \
| sed -E 's/.*:[[:space:]]*(true|false).*/\\1/' \
| tr -d ' ' \
|| true)\"
if [ \"\${CURRENT:-}\" = \"\$VERIFIED\" ]; then
echo \"[keycloak][emailVerified] unchanged: \$USERNAME (\$USER_ID) = \$CURRENT\"
exit 0
fi
${KEYCLOAK_KCADM} update users/\$USER_ID -r \"\$REALM\" -s emailVerified=\"\$VERIFIED\"
echo \"[keycloak][emailVerified] updated: \$USERNAME (\$USER_ID) -> \$VERIFIED\"
"
@@ -1,21 +1,17 @@
---
# Set emailVerified=true for all configured users from vars.
# Contract:
# - users is ALWAYS a dict
# - username is ALWAYS stored in users.<key>.username
# - dict key is ignored completely
- name: "🔧 Set emailVerified=true for users in realm '{{ KEYCLOAK_REALM }}'"
ansible.builtin.script: shell/set_email_verified.sh
environment:
KEYCLOAK_EXEC_CONTAINER: "{{ KEYCLOAK_EXEC_CONTAINER }}"
KEYCLOAK_KCADM: "{{ KEYCLOAK_KCADM }}"
KEYCLOAK_REALM: "{{ KEYCLOAK_REALM }}"
KEYCLOAK_USERNAME: "{{ item.value.username }}"
KEYCLOAK_EMAIL_VERIFIED: "true"
loop: "{{ lookup('users_non_reserved') | dict2items }}"
loop_control:
label: "{{ item.value.username }}"
register: kc_email_verified_updates
changed_when: true
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
ansible.builtin.include_tasks:
file: "{{ role_path }}/tasks/utils/rest_script.yml"
apply:
delegate_to: "{{ KEYCLOAK_HOST_NODE }}"
vars:
rest_script: email_verified.py
rest_script_env:
USERNAMES: "{{ lookup('users_non_reserved') | dict2items | map(attribute='value.username') | join(' ') }}"
rest_script_ok_rcs: [0]
rest_script_changed: "updated=[1-9]"
rest_script_register: kc_email_verified
- name: "📊 Report how many users got their email marked verified"
ansible.builtin.debug:
msg: "{{ kc_email_verified.stdout_lines | select('match', '^summary ') | list }}"
@@ -0,0 +1,129 @@
import contextlib
import io
import os
import sys
import unittest
import urllib.parse
from unittest import mock
from . import PROJECT_ROOT
sys.path.insert(0, str(PROJECT_ROOT / "roles/web-app-keycloak/files/python"))
import email_verified as m # noqa: E402
class FakeKeycloak:
def __init__(self):
self.users = {}
self.puts = []
self.failing = set()
def add_user(self, username, verified, **extra):
user_id = f"u-{len(self.users) + 1}"
self.users[user_id] = {
"id": user_id,
"username": username,
"emailVerified": verified,
**extra,
}
return user_id
def call(self, method, path, payload=None, timeout=60):
route, _, query = path.partition("?")
if method == "GET" and route == "users":
wanted = urllib.parse.parse_qs(query)["username"][0]
if wanted in self.failing:
raise m.KeycloakError(f"GET {path}: HTTP 500")
return [
dict(u) for u in self.users.values() if wanted.lower() in u["username"]
], ""
if method == "GET" and route.startswith("users/"):
return dict(self.users[route.split("/")[1]]), ""
if method == "PUT" and route.startswith("users/"):
user_id = route.split("/")[1]
self.puts.append(user_id)
self.users[user_id] = dict(payload)
return None, ""
raise AssertionError(f"unexpected call {method} {path}")
class TestVerify(unittest.TestCase):
def test_an_unverified_user_is_updated_and_keeps_its_other_fields(self):
kc = FakeKeycloak()
user_id = kc.add_user("biber", False, email="biber@example.org")
self.assertEqual(m.verify(kc, "biber"), "updated")
self.assertIs(kc.users[user_id]["emailVerified"], True)
self.assertEqual(kc.users[user_id]["email"], "biber@example.org")
def test_a_verified_user_is_left_alone(self):
kc = FakeKeycloak()
kc.add_user("biber", True)
self.assertEqual(m.verify(kc, "biber"), "unchanged")
self.assertEqual(kc.puts, [])
def test_an_absent_user_is_reported_missing(self):
kc = FakeKeycloak()
self.assertEqual(m.verify(kc, "biber"), "missing")
self.assertEqual(kc.puts, [])
def test_only_the_exact_username_is_touched(self):
kc = FakeKeycloak()
other = kc.add_user("contact2", False)
target = kc.add_user("contact", False)
self.assertEqual(m.verify(kc, "Contact"), "updated")
self.assertEqual(kc.puts, [target])
self.assertIs(kc.users[other]["emailVerified"], False)
class TestMain(unittest.TestCase):
def _run(self, kc, usernames):
out = io.StringIO()
env = {
"KC_URL": "http://kc",
"KC_PID": "1",
"KC_REALM": "r",
"USERNAMES": usernames,
}
with (
mock.patch.dict(os.environ, env),
mock.patch.object(m, "Keycloak", lambda *args: kc),
mock.patch.object(m, "container_credentials", lambda path: ("a", "p")),
contextlib.redirect_stdout(out),
contextlib.redirect_stderr(io.StringIO()),
):
code = m.main()
return code, out.getvalue().splitlines()
def test_the_summary_counts_every_outcome(self):
kc = FakeKeycloak()
kc.add_user("administrator", True)
kc.add_user("biber", False)
code, lines = self._run(kc, "administrator biber ghost")
self.assertEqual(code, 0)
self.assertEqual(lines[-1], "summary updated=1 unchanged=1 missing=1 failed=0")
def test_one_failed_user_fails_the_run_but_not_the_others(self):
kc = FakeKeycloak()
kc.add_user("administrator", False)
kc.add_user("biber", False)
kc.failing.add("administrator")
code, lines = self._run(kc, "administrator biber")
self.assertEqual(code, 1)
self.assertIn("failed administrator", lines)
self.assertIn("updated biber", lines)
def test_a_missing_environment_key_fails_before_any_call(self):
kc = FakeKeycloak()
with (
mock.patch.dict(os.environ, {}, clear=True),
mock.patch.object(m, "Keycloak", lambda *args: kc),
contextlib.redirect_stdout(io.StringIO()) as out,
):
code = m.main()
self.assertEqual(code, 1)
self.assertTrue(out.getvalue().startswith("setup-failed"))
if __name__ == "__main__":
unittest.main()