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:
405
backend/app/keycloak.py
Normal file
405
backend/app/keycloak.py
Normal 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()
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -5,3 +5,4 @@ Flask-CORS>=4.0
|
||||
cryptography>=42.0
|
||||
python-dotenv>=1.0
|
||||
gunicorn>=22.0
|
||||
requests>=2.31
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user