mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-07 03:15:56 +00:00
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:
1 parent
9301220d05
commit
49aa920034
4 files changed
+228
-83
No files matched your search
+84
@@ -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()
|
||||
Reference in new issue
Block a user