Add Keycloak Admin API client with route integration and admin discrepancy stub

Introduces a full Keycloak REST API client (app/keycloak.py) that syncs
user CRUD, project groups, and group membership to Keycloak. Operates in
disabled mode when KEYCLOAK_URL is unset. All local DB operations succeed
first; Keycloak failures are logged and surfaced as warnings, never blocking.
Users are disabled in Keycloak on delete, never removed. Adds a Coming Soon
discrepancy management section to the admin panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 16:47:32 -07:00
parent 0a2944ebb6
commit 79e71f0244
6 changed files with 541 additions and 7 deletions

405
backend/app/keycloak.py Normal file
View File

@@ -0,0 +1,405 @@
"""
Keycloak Admin REST API client.
Provides a thin wrapper around the Keycloak Admin API for syncing users,
groups (projects), and group membership. All methods are designed to be
called alongside local DB operations — Keycloak failures are logged and
surfaced to callers via KeycloakError so routes can decide how to respond.
Config (all via environment / Config class):
KEYCLOAK_URL Base URL, e.g. https://keycloak.example.com
KEYCLOAK_REALM Realm name
KEYCLOAK_CLIENT_ID Service-account client ID
KEYCLOAK_CLIENT_SECRET Service-account client secret
When KEYCLOAK_URL is not set, the client operates in "disabled" mode —
every public method returns None immediately and no HTTP calls are made.
This lets the rest of the app run without Keycloak in dev.
"""
import logging
import time
import requests
from flask import current_app
logger = logging.getLogger(__name__)
# ── Exceptions ────────────────────────────────────────────────────
class KeycloakError(Exception):
"""Raised when a Keycloak API call fails."""
def __init__(self, message, status_code=None, response_body=None):
super().__init__(message)
self.status_code = status_code
self.response_body = response_body
class KeycloakConnectionError(KeycloakError):
"""Raised when we cannot reach Keycloak at all."""
class KeycloakAuthError(KeycloakError):
"""Raised on 401/403 from Keycloak (bad credentials or missing roles)."""
class KeycloakConflictError(KeycloakError):
"""Raised on 409 (duplicate user/group, etc.)."""
class KeycloakNotFoundError(KeycloakError):
"""Raised on 404 from Keycloak."""
# ── Client ────────────────────────────────────────────────────────
class KeycloakClient:
"""Stateless client — reads config from Flask app context each call."""
# Cached token + expiry (module-level so it survives across requests)
_token: str | None = None
_token_expires_at: float = 0
# ── Helpers ───────────────────────────────────────────────────
@property
def enabled(self) -> bool:
return bool(current_app.config.get("KEYCLOAK_URL"))
@property
def _base(self) -> str:
return current_app.config["KEYCLOAK_URL"].rstrip("/")
@property
def _realm(self) -> str:
return current_app.config["KEYCLOAK_REALM"]
@property
def _admin_url(self) -> str:
return f"{self._base}/admin/realms/{self._realm}"
def _get_token(self) -> str:
"""Obtain or reuse a service-account access token."""
now = time.time()
if self._token and now < self._token_expires_at - 30:
return self._token
token_url = (
f"{self._base}/realms/{self._realm}/protocol/openid-connect/token"
)
try:
resp = requests.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": current_app.config["KEYCLOAK_CLIENT_ID"],
"client_secret": current_app.config["KEYCLOAK_CLIENT_SECRET"],
},
timeout=10,
)
except requests.ConnectionError as exc:
raise KeycloakConnectionError(
f"Cannot reach Keycloak at {self._base}"
) from exc
except requests.Timeout as exc:
raise KeycloakConnectionError(
"Keycloak token request timed out"
) from exc
if resp.status_code != 200:
raise KeycloakAuthError(
"Failed to obtain Keycloak service-account token",
status_code=resp.status_code,
response_body=resp.text,
)
data = resp.json()
self._token = data["access_token"]
self._token_expires_at = now + data.get("expires_in", 300)
return self._token
def _headers(self) -> dict:
return {
"Authorization": f"Bearer {self._get_token()}",
"Content-Type": "application/json",
}
def _request(self, method: str, path: str, **kwargs):
"""
Make an authenticated request to the Keycloak Admin API.
Raises typed KeycloakError subclasses so callers can handle
specific failure modes (conflict, not-found, auth, connection).
"""
url = f"{self._admin_url}{path}"
kwargs.setdefault("timeout", 10)
kwargs.setdefault("headers", self._headers())
try:
resp = requests.request(method, url, **kwargs)
except requests.ConnectionError as exc:
raise KeycloakConnectionError(
f"Cannot reach Keycloak: {method} {path}"
) from exc
except requests.Timeout as exc:
raise KeycloakConnectionError(
f"Keycloak request timed out: {method} {path}"
) from exc
if resp.status_code in (401, 403):
# Token may have been revoked; clear cache and let caller retry
self._token = None
raise KeycloakAuthError(
f"Keycloak auth failed: {method} {path}",
status_code=resp.status_code,
response_body=resp.text,
)
if resp.status_code == 404:
raise KeycloakNotFoundError(
f"Keycloak resource not found: {method} {path}",
status_code=404,
response_body=resp.text,
)
if resp.status_code == 409:
raise KeycloakConflictError(
f"Keycloak conflict: {method} {path}",
status_code=409,
response_body=resp.text,
)
if resp.status_code >= 400:
raise KeycloakError(
f"Keycloak error {resp.status_code}: {method} {path}",
status_code=resp.status_code,
response_body=resp.text,
)
return resp
# ── User operations ───────────────────────────────────────────
def create_user(self, email: str, name: str) -> str | None:
"""
Create a user in Keycloak. Returns the Keycloak user ID,
or None if Keycloak is disabled.
Raises KeycloakConflictError if email/username already exists.
"""
if not self.enabled:
return None
# Use email as username for simplicity
payload = {
"username": email,
"email": email,
"firstName": name.split()[0] if name else "",
"lastName": " ".join(name.split()[1:]) if name and " " in name else "",
"enabled": True,
"emailVerified": True,
}
resp = self._request("POST", "/users", json=payload)
# 201 Created — extract ID from Location header
location = resp.headers.get("Location", "")
kc_id = location.rsplit("/", 1)[-1] if location else None
if not kc_id:
# Fallback: look up by email
kc_id = self.find_user_by_email(email)
logger.info("Keycloak user created: %s (kc_id=%s)", email, kc_id)
return kc_id
def update_user(self, keycloak_id: str, email: str, name: str, enabled: bool = True):
"""Update user attributes in Keycloak."""
if not self.enabled or not keycloak_id:
return
payload = {
"username": email,
"email": email,
"firstName": name.split()[0] if name else "",
"lastName": " ".join(name.split()[1:]) if name and " " in name else "",
"enabled": enabled,
}
self._request("PUT", f"/users/{keycloak_id}", json=payload)
logger.info("Keycloak user updated: %s", keycloak_id)
def disable_user(self, keycloak_id: str):
"""
Disable a user in Keycloak (never delete — local data must persist).
"""
if not self.enabled or not keycloak_id:
return
self._request("PUT", f"/users/{keycloak_id}", json={"enabled": False})
logger.info("Keycloak user disabled: %s", keycloak_id)
def find_user_by_email(self, email: str) -> str | None:
"""Look up a Keycloak user ID by email. Returns None if not found."""
if not self.enabled:
return None
resp = self._request("GET", "/users", params={"email": email, "exact": "true"})
users = resp.json()
if users:
return users[0]["id"]
return None
def get_user(self, keycloak_id: str) -> dict | None:
"""Fetch full user representation from Keycloak."""
if not self.enabled or not keycloak_id:
return None
resp = self._request("GET", f"/users/{keycloak_id}")
return resp.json()
# ── Group operations (groups == projects) ─────────────────────
def create_group(self, name: str) -> str | None:
"""
Create a group in Keycloak. Returns the Keycloak group ID,
or None if Keycloak is disabled.
"""
if not self.enabled:
return None
resp = self._request("POST", "/groups", json={"name": name})
location = resp.headers.get("Location", "")
group_id = location.rsplit("/", 1)[-1] if location else None
if not group_id:
group_id = self.find_group_by_name(name)
logger.info("Keycloak group created: %s (id=%s)", name, group_id)
return group_id
def update_group(self, group_id: str, name: str):
"""Rename a group in Keycloak."""
if not self.enabled or not group_id:
return
self._request("PUT", f"/groups/{group_id}", json={"name": name})
logger.info("Keycloak group updated: %s", group_id)
def find_group_by_name(self, name: str) -> str | None:
"""Look up a Keycloak group ID by exact name."""
if not self.enabled:
return None
resp = self._request("GET", "/groups", params={"search": name, "exact": "true"})
groups = resp.json()
for g in groups:
if g["name"] == name:
return g["id"]
return None
def get_group_members(self, group_id: str) -> list[dict]:
"""List all members of a Keycloak group."""
if not self.enabled or not group_id:
return []
resp = self._request("GET", f"/groups/{group_id}/members")
return resp.json()
# ── Group membership ──────────────────────────────────────────
def add_user_to_group(self, keycloak_user_id: str, keycloak_group_id: str):
"""
Add a user to a group in Keycloak. Idempotent (PUT).
"""
if not self.enabled or not keycloak_user_id or not keycloak_group_id:
return
self._request(
"PUT",
f"/users/{keycloak_user_id}/groups/{keycloak_group_id}",
)
logger.info(
"Keycloak: user %s added to group %s",
keycloak_user_id,
keycloak_group_id,
)
def remove_user_from_group(self, keycloak_user_id: str, keycloak_group_id: str):
"""Remove a user from a group in Keycloak."""
if not self.enabled or not keycloak_user_id or not keycloak_group_id:
return
self._request(
"DELETE",
f"/users/{keycloak_user_id}/groups/{keycloak_group_id}",
)
logger.info(
"Keycloak: user %s removed from group %s",
keycloak_user_id,
keycloak_group_id,
)
# ── Role operations (privileges) ──────────────────────────────
def get_realm_roles(self) -> list[dict]:
"""List all realm-level roles."""
if not self.enabled:
return []
resp = self._request("GET", "/roles")
return resp.json()
def assign_realm_roles_to_group(self, group_id: str, roles: list[dict]):
"""
Assign realm roles to a group.
`roles` should be a list of role representations:
[{"id": "...", "name": "admin"}, ...]
"""
if not self.enabled or not group_id or not roles:
return
self._request(
"POST",
f"/groups/{group_id}/role-mappings/realm",
json=roles,
)
role_names = [r["name"] for r in roles]
logger.info(
"Keycloak: assigned roles %s to group %s",
role_names,
group_id,
)
def remove_realm_roles_from_group(self, group_id: str, roles: list[dict]):
"""Remove realm roles from a group."""
if not self.enabled or not group_id or not roles:
return
self._request(
"DELETE",
f"/groups/{group_id}/role-mappings/realm",
json=roles,
)
role_names = [r["name"] for r in roles]
logger.info(
"Keycloak: removed roles %s from group %s",
role_names,
group_id,
)
def get_group_realm_roles(self, group_id: str) -> list[dict]:
"""Get realm roles currently assigned to a group."""
if not self.enabled or not group_id:
return []
resp = self._request("GET", f"/groups/{group_id}/role-mappings/realm")
return resp.json()
# Module-level singleton
keycloak = KeycloakClient()

View File

@@ -1,3 +1,4 @@
import logging
import re
from datetime import datetime, timezone
@@ -6,10 +7,13 @@ from flask import request, jsonify, session
from app.auth.decorators import login_required
from app.audit import log_audit, AuditLog
from app.extensions import db
from app.keycloak import keycloak, KeycloakError
from app.projects import projects_bp
from app.projects.models import Project
from app.users.models import User, ProjectUser, BILLING_TYPES, PRIVILEGE_LEVELS
logger = logging.getLogger(__name__)
@projects_bp.route("", methods=["GET"])
@login_required
@@ -91,7 +95,18 @@ def create_project():
log_audit("project", project.id, "created", {"key": project.key, "name": project.name})
return jsonify(project.to_dict()), 201
# Create corresponding Keycloak group
kc_warning = None
try:
keycloak.create_group(project.key)
except KeycloakError as exc:
logger.warning("Keycloak sync failed on project create: %s", exc)
kc_warning = f"Project created locally but Keycloak group sync failed: {exc}"
result = project.to_dict()
if kc_warning:
result["keycloak_warning"] = kc_warning
return jsonify(result), 201
@projects_bp.route("/<string:key>", methods=["GET"])
@@ -208,7 +223,21 @@ def add_project_user(key):
"billing_type": billing_type,
})
return jsonify(membership.to_dict()), 201
# Sync group membership to Keycloak
kc_warning = None
if user.keycloak_id:
try:
kc_group_id = keycloak.find_group_by_name(project.key)
if kc_group_id:
keycloak.add_user_to_group(user.keycloak_id, kc_group_id)
except KeycloakError as exc:
logger.warning("Keycloak sync failed on add_project_user: %s", exc)
kc_warning = f"Membership created locally but Keycloak sync failed: {exc}"
result = membership.to_dict()
if kc_warning:
result["keycloak_warning"] = kc_warning
return jsonify(result), 201
@projects_bp.route("/<string:key>/users/<int:user_id>", methods=["PUT"])
@@ -313,6 +342,7 @@ def remove_project_user(key, user_id):
}), 409
user_name = membership.user.name
keycloak_id = membership.user.keycloak_id
db.session.delete(membership)
db.session.commit()
@@ -323,7 +353,21 @@ def remove_project_user(key, user_id):
"project_name": project.name,
})
return jsonify({"message": f"User '{user_name}' removed from project"}), 200
# Sync group membership removal to Keycloak
kc_warning = None
if keycloak_id:
try:
kc_group_id = keycloak.find_group_by_name(project.key)
if kc_group_id:
keycloak.remove_user_from_group(keycloak_id, kc_group_id)
except KeycloakError as exc:
logger.warning("Keycloak sync failed on remove_project_user: %s", exc)
kc_warning = f"Membership removed locally but Keycloak sync failed: {exc}"
result = {"message": f"User '{user_name}' removed from project"}
if kc_warning:
result["keycloak_warning"] = kc_warning
return jsonify(result), 200
# ── Project audit history ─────────────────────────────────────────

View File

@@ -1,3 +1,4 @@
import logging
from datetime import datetime, timezone
from flask import request, jsonify
@@ -5,9 +6,12 @@ from flask import request, jsonify
from app.auth.decorators import login_required
from app.audit import log_audit, AuditLog
from app.extensions import db
from app.keycloak import keycloak, KeycloakError
from app.users import users_bp
from app.users.models import User, ProjectUser
logger = logging.getLogger(__name__)
# ── User CRUD ──────────────────────────────────────────────────
@@ -68,7 +72,22 @@ def create_user():
log_audit("user", user.id, "created", {"name": user.name, "email": user.email})
return jsonify(user.to_dict(include_projects=True)), 201
# Sync to Keycloak (non-blocking — local user is already persisted)
kc_warning = None
if not user.keycloak_id:
try:
kc_id = keycloak.create_user(email=user.email, name=user.name)
if kc_id:
user.keycloak_id = kc_id
db.session.commit()
except KeycloakError as exc:
logger.warning("Keycloak sync failed on user create: %s", exc)
kc_warning = f"User created locally but Keycloak sync failed: {exc}"
result = user.to_dict(include_projects=True)
if kc_warning:
result["keycloak_warning"] = kc_warning
return jsonify(result), 201
@users_bp.route("/<int:user_id>", methods=["GET"])
@@ -112,7 +131,24 @@ def update_user(user_id):
log_audit("user", user.id, "updated", changes)
return jsonify(user.to_dict(include_projects=True)), 200
# Sync changes to Keycloak
kc_warning = None
if user.keycloak_id and changes:
try:
keycloak.update_user(
keycloak_id=user.keycloak_id,
email=user.email,
name=user.name,
enabled=user.active,
)
except KeycloakError as exc:
logger.warning("Keycloak sync failed on user update: %s", exc)
kc_warning = f"User updated locally but Keycloak sync failed: {exc}"
result = user.to_dict(include_projects=True)
if kc_warning:
result["keycloak_warning"] = kc_warning
return jsonify(result), 200
@users_bp.route("/<int:user_id>", methods=["DELETE"])
@@ -123,6 +159,8 @@ def delete_user(user_id):
return jsonify({"error": "User not found"}), 404
user_name = user.name
keycloak_id = user.keycloak_id
# Remove all project memberships first
ProjectUser.query.filter_by(user_id=user_id).delete()
db.session.delete(user)
@@ -130,7 +168,19 @@ def delete_user(user_id):
log_audit("user", user_id, "deleted", {"name": user_name})
return jsonify({"message": f"User '{user_name}' deleted"}), 200
# Disable in Keycloak (never delete — local data must persist)
kc_warning = None
if keycloak_id:
try:
keycloak.disable_user(keycloak_id)
except KeycloakError as exc:
logger.warning("Keycloak sync failed on user delete: %s", exc)
kc_warning = f"User deleted locally but Keycloak disable failed: {exc}"
result = {"message": f"User '{user_name}' deleted"}
if kc_warning:
result["keycloak_warning"] = kc_warning
return jsonify(result), 200
# ── User audit history ─────────────────────────────────────────

View File

@@ -41,6 +41,12 @@ class Config:
# Upload limit
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
# Keycloak (disabled when KEYCLOAK_URL is empty)
KEYCLOAK_URL = os.environ.get("KEYCLOAK_URL", "")
KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "osa")
KEYCLOAK_CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "osa-admin-client")
KEYCLOAK_CLIENT_SECRET = os.environ.get("KEYCLOAK_CLIENT_SECRET", "")
@staticmethod
def get_fernet_key():
key = os.environ.get("FERNET_KEY")

View File

@@ -5,3 +5,4 @@ Flask-CORS>=4.0
cryptography>=42.0
python-dotenv>=1.0
gunicorn>=22.0
requests>=2.31

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { format, parseISO } from "date-fns";
import { Settings } from "lucide-react";
import { Settings, Shield } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -242,6 +242,34 @@ export default function AdminPage() {
</Card>
</section>
{/* Keycloak Discrepancies */}
<section>
<h2 className="mb-3 text-lg font-semibold">Keycloak Sync</h2>
<Card>
<CardContent>
<div className="rounded-lg border-2 border-dashed border-border bg-muted/20 p-8">
<div className="mx-auto max-w-md text-center">
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-muted">
<Shield className="size-6 text-muted-foreground" />
</div>
<h3 className="mt-4 text-base font-medium">
Discrepancy Management
<span className="ml-2 inline-flex items-center rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">
Coming Soon
</span>
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Compare local users and project memberships against Keycloak
to identify sync discrepancies users missing from Keycloak,
group memberships out of sync, or stale Keycloak entries with
no local match. Resolve conflicts without deleting local data.
</p>
</div>
</div>
</CardContent>
</Card>
</section>
{/* Feedback Detail Dialog */}
<Dialog
open={selectedId !== null}