Files
mgmt/scripts/audit-kc-groups.py
scott e21c386349 Reduce standard project apps to mgmtsuite only
Drop bitbucket, srm, and coverity from STANDARD_APPS so new projects only
auto-provision the mgmtsuite-{read,write,admin} child groups. The other three
remain fully supported as custom child groups (created via the group-management
UI or CSV import) — they're just no longer auto-created per project, and the
service account no longer auto-joins their -read groups.

Backend reads STANDARD_APPS as the single source of truth (group creation,
service-account onboarding, child-group classification), so those adapt
automatically. Kept the audit script's mirrored copy in sync (it explicitly
must match), updated the project-creation test to assert exactly 3 standard
subgroups, and refreshed CLAUDE.md.

Existing projects keep their bitbucket/srm/coverity groups; nothing is deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:00:57 -07:00

275 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""Audit Keycloak project groups against the expected naming and membership.
Read-only. For every project in the local database this script checks:
1. The top-level Keycloak group exists.
2. All standard child groups exist ({KEY}-{app}-{read|write|admin} for
mgmtsuite).
3. No child groups use legacy/unexpected naming — in particular the old
`{KEY}-mgmt-*` convention, which the permission model does not read
(users in those groups silently have no app permissions).
4. No users are members of the top-level group without also being in at
least one `{KEY}-mgmtsuite-*` child group (i.e. project membership
that grants no permission in this app).
It also reports top-level Keycloak groups that have no matching project in
the database.
Usage:
python scripts/audit-kc-groups.py # audit all projects
python scripts/audit-kc-groups.py KEY1 KEY2 # audit specific projects
Exits 0 when clean, 1 when findings were reported (cron-friendly).
"""
import argparse
import json
import re
import subprocess
import sys
import urllib.parse
import urllib.request
from urllib.error import HTTPError
# --- CONFIG ---
# These should match scripts/delete-projects.py
DB_URL = "postgresql://user:pass@host/db"
KC_URL = "https://keycloak.osa.af.mil"
KC_REALM = ""
KC_CLIENT_ID = ""
KC_CLIENT_SECRET = ""
# --------------
# Must match backend/app/constants.py STANDARD_APPS
STANDARD_APPS = {
"mgmtsuite": ["read", "write", "admin"],
}
# Old child-group convention replaced by `mgmtsuite`; flagged loudly because
# memberships in these groups grant nothing.
LEGACY_APP_NAMES = ("mgmt",)
# Top-level KC groups that intentionally have no DB project. They are still
# audited for legacy child naming (VELA-mgmtsuite-admin gates global admin),
# but not for missing standard children or orphan status.
KC_ONLY_GROUPS = ("VELA",)
PAGE_SIZE = 100
def run_query(query, single_val=False):
"""Executes a PostgreSQL query via psql and returns results cleanly."""
cmd = ["psql", DB_URL, "-t", "-A", "-c", query]
try:
res = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode().strip()
if single_val:
return res
return [line for line in res.splitlines() if line]
except subprocess.CalledProcessError:
return "" if single_val else []
def req(method, url, data=None, headers=None):
"""Standard HTTP request handling."""
req_obj = urllib.request.Request(
url, data=data, method=method, headers=headers or {}
)
try:
with urllib.request.urlopen(req_obj) as r:
body = r.read().decode("utf-8")
ct = r.getheader("Content-Type") or ""
return json.loads(body) if "application/json" in ct else body
except HTTPError as e:
return e
def get_token():
url = f"{KC_URL}/realms/{KC_REALM}/protocol/openid-connect/token"
data = f"grant_type=client_credentials&client_id={KC_CLIENT_ID}&client_secret={KC_CLIENT_SECRET}".encode()
with urllib.request.urlopen(urllib.request.Request(url, data=data)) as r:
return json.loads(r.read().decode())["access_token"]
def paginate(url_base, headers, params=None):
"""Yield items from a paginated KC admin endpoint."""
first = 0
while True:
query = dict(params or {})
query.update({"first": first, "max": PAGE_SIZE})
url = f"{url_base}?{urllib.parse.urlencode(query)}"
page = req("GET", url, headers=headers)
if not isinstance(page, list):
print(f" WARN: failed to fetch {url_base}: {page}")
return
yield from page
if len(page) < PAGE_SIZE:
return
first += PAGE_SIZE
def get_top_level_groups(headers):
"""Return {name: id} for all top-level groups in the realm."""
url = f"{KC_URL}/admin/realms/{KC_REALM}/groups"
return {g["name"]: g["id"] for g in paginate(url, headers)}
def get_children(group_id, headers):
"""Return [{name, id}, ...] child groups of a group."""
url = f"{KC_URL}/admin/realms/{KC_REALM}/groups/{group_id}/children"
return [{"name": g["name"], "id": g["id"]} for g in paginate(url, headers)]
def get_members(group_id, headers):
"""Return {username: user_id} members of a group."""
url = f"{KC_URL}/admin/realms/{KC_REALM}/groups/{group_id}/members"
return {
u.get("username", u["id"]): u["id"]
for u in paginate(url, headers, params={"briefRepresentation": "true"})
}
def expected_children(project_key):
return {
f"{project_key}-{app}-{perm}"
for app, perms in STANDARD_APPS.items()
for perm in perms
}
def classify_child(project_key, name):
"""Return 'standard', 'legacy', or 'custom' for a child group name."""
if name in expected_children(project_key):
return "standard"
for legacy in LEGACY_APP_NAMES:
if re.fullmatch(rf"{re.escape(project_key)}-{legacy}-(read|write|admin)", name):
return "legacy"
return "custom"
def audit_project(project_key, group_map, headers, kc_only=False):
"""Audit one project. Returns a list of finding strings."""
findings = []
group_id = group_map.get(project_key)
if not group_id:
return [f"top-level group '{project_key}' is MISSING in Keycloak"]
children = get_children(group_id, headers)
child_names = {c["name"] for c in children}
# Missing standard children (KC-only groups like VELA aren't created by
# the app, so their expected children may legitimately differ)
if not kc_only:
for missing in sorted(expected_children(project_key) - child_names):
findings.append(f"missing standard child group: {missing}")
# Legacy / unexpected child names
legacy_groups = []
for child in sorted(children, key=lambda c: c["name"]):
kind = classify_child(project_key, child["name"])
if kind == "legacy":
members = get_members(child["id"], headers)
legacy_groups.append((child["name"], members))
for name, members in legacy_groups:
detail = f" (members: {', '.join(sorted(members))})" if members else " (empty)"
findings.append(
f"LEGACY child group '{name}' — memberships here grant no permissions{detail}"
)
# Top-level members with no mgmtsuite child membership
top_members = get_members(group_id, headers)
if top_members:
mgmtsuite_members = set()
for child in children:
if child["name"].startswith(f"{project_key}-mgmtsuite-"):
mgmtsuite_members.update(get_members(child["id"], headers))
for username in sorted(set(top_members) - mgmtsuite_members):
findings.append(
f"user '{username}' is in '{project_key}' but in no "
f"{project_key}-mgmtsuite-* child group (no app permissions)"
)
return findings
def main():
parser = argparse.ArgumentParser(
description="Audit Keycloak project group naming and membership."
)
parser.add_argument(
"keys", nargs="*",
help="Project keys to audit (default: all projects in the database)",
)
args = parser.parse_args()
print("Fetching projects from database...")
db_projects = run_query("SELECT key FROM projects ORDER BY key;")
if not db_projects:
print("No projects found in database. Check DB_URL.")
sys.exit(1)
print(f"Found {len(db_projects)} projects in DB.")
if args.keys:
unknown = [k for k in args.keys if k not in db_projects]
if unknown:
print(f"Unknown project keys (not in DB): {', '.join(unknown)}")
sys.exit(1)
targets = args.keys
else:
targets = db_projects
try:
token = get_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
except Exception as e:
print(f"Authentication Failed with Keycloak Server: {e}")
sys.exit(1)
print("Fetching top-level groups from Keycloak...")
group_map = get_top_level_groups(headers)
total_findings = 0
print("\n--- Audit Results ---")
for key in targets:
findings = audit_project(key, group_map, headers)
if findings:
total_findings += len(findings)
print(f"\n{key}:")
for f in findings:
print(f" - {f}")
# KC-only groups (e.g. VELA) and orphan detection — only meaningful when
# auditing everything.
if not args.keys:
for name in KC_ONLY_GROUPS:
if name in group_map:
findings = audit_project(name, group_map, headers, kc_only=True)
if findings:
total_findings += len(findings)
print(f"\n{name} (KC-only group):")
for f in findings:
print(f" - {f}")
orphans = sorted(set(group_map) - set(db_projects) - set(KC_ONLY_GROUPS))
if orphans:
total_findings += len(orphans)
print("\nTop-level Keycloak groups with no matching DB project:")
for name in orphans:
print(f" - {name}")
print()
if total_findings:
print(f"Found {total_findings} finding(s).")
sys.exit(1)
print("All audited projects look clean.")
sys.exit(0)
if __name__ == "__main__":
main()