Add KC inbound sync worker and settings tab badges

Periodic background thread pulls users and group memberships from
Keycloak into the local DB every 30s (configurable via admin settings).
Closes the gap where users added directly in KC didn't appear in the
app until a manual sync.

Also adds count badges to the Keycloak and Feedback tabs on the admin
settings page so issues are immediately visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 12:28:22 -07:00
parent b28fe8c15f
commit 9850d854eb
4 changed files with 116 additions and 2 deletions

View File

@@ -0,0 +1,89 @@
"""
KC Inbound Sync Worker — background daemon thread that periodically pulls
user and group membership state from Keycloak into the local database.
The outbound KC queue (kc_worker.py) pushes app changes TO Keycloak.
This worker closes the loop by pulling KC state BACK, catching any
changes made directly in the KC admin console or by other systems.
Interval is configurable via the ``kc_inbound_sync_minutes`` setting
(default 10).
"""
import logging
import threading
import time
from app.extensions import db
from app.keycloak import keycloak, KeycloakError
from app.keycloak_sync import sync_users_from_keycloak, sync_permissions_from_keycloak
from app.settings.models import Setting
logger = logging.getLogger(__name__)
DEFAULT_INTERVAL_SECONDS = 30
class KcInboundSyncWorker:
"""Background thread that syncs KC users and permissions on a timer."""
def __init__(self, app):
self.app = app
self._stop_event = threading.Event()
self._thread = None
def start(self):
self._thread = threading.Thread(
target=self._run, daemon=True, name="kc-inbound-sync"
)
self._thread.start()
logger.info("KC inbound sync worker started (default interval: %ds)", DEFAULT_INTERVAL_SECONDS)
def stop(self):
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
def _get_interval_seconds(self):
try:
with self.app.app_context():
val = Setting.get("kc_inbound_sync_seconds")
if val is not None:
return max(5, int(val))
except Exception:
pass
return DEFAULT_INTERVAL_SECONDS
def _run(self):
while not self._stop_event.is_set():
interval = self._get_interval_seconds()
if self._stop_event.wait(timeout=interval):
break
try:
with self.app.app_context():
if not keycloak.enabled:
continue
user_result = sync_users_from_keycloak(keycloak)
perm_result = sync_permissions_from_keycloak(keycloak)
changes = (
user_result.get("created", 0)
+ user_result.get("updated", 0)
+ perm_result.get("permissions_created", 0)
)
if changes:
logger.info(
"KC inbound sync: users(created=%d, updated=%d), permissions(projects=%d, rows=%d)",
user_result.get("created", 0),
user_result.get("updated", 0),
perm_result.get("projects_synced", 0),
perm_result.get("permissions_created", 0),
)
else:
logger.debug("KC inbound sync: no changes")
except KeycloakError as exc:
logger.warning("KC inbound sync failed (will retry): %s", exc)
except Exception:
logger.exception("KC inbound sync unexpected error")

View File

@@ -26,6 +26,10 @@ with app.app_context():
worker.recover_stale()
worker.start()
from app.kc_inbound_sync import KcInboundSyncWorker
inbound_sync = KcInboundSyncWorker(app)
inbound_sync.start()
# Start downtime reminder worker (background daemon thread)
from app.downtime_reminder import DowntimeReminderWorker
reminder_worker = DowntimeReminderWorker(app)

View File

@@ -24,6 +24,7 @@ export const FORM_NUMERIC_KEYS = [
"verification_interval_days",
"downtime_reminder_max_days",
"unsponsored_deletion_days",
"kc_inbound_sync_seconds",
"billing_day_of_month",
] as const;

View File

@@ -85,6 +85,7 @@ import {
} from "@/lib/constants";
import { KeycloakDiscrepancySection } from "@/components/admin/keycloak-discrepancy-section";
import { KcSyncErrorsSection } from "@/components/admin/kc-sync-errors-section";
import { useKcSyncErrorCount } from "@/hooks/use-kc-sync-errors";
import { AgencyServiceManager } from "@/components/admin/agency-service-manager";
import { CostCenterManager } from "@/components/admin/cost-center-manager";
import { DowntimeApplicationsManager } from "@/components/admin/downtime-applications-manager";
@@ -1331,6 +1332,8 @@ export default function AdminPage() {
}, [setSearchParams]);
const { data: feedbackList, isLoading } = useFeedbackList();
const { data: kcErrorCount } = useKcSyncErrorCount(true);
const unreadFeedbackCount = feedbackList?.filter((f) => f.status === "unread").length ?? 0;
const [selectedId, setSelectedId] = useState<number | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const { data: detail } = useFeedbackDetail(selectedId);
@@ -1361,8 +1364,22 @@ export default function AdminPage() {
<TabsTrigger value="notifications">Notifications</TabsTrigger>
<TabsTrigger value="content">Content</TabsTrigger>
<TabsTrigger value="system">System</TabsTrigger>
<TabsTrigger value="feedback">Feedback</TabsTrigger>
<TabsTrigger value="keycloak">Keycloak</TabsTrigger>
<TabsTrigger value="feedback" className="gap-1.5">
Feedback
{!!unreadFeedbackCount && (
<span className="inline-flex items-center justify-center rounded-full bg-blue-500 px-1.5 py-0.5 text-[10px] font-bold leading-none text-white">
{unreadFeedbackCount > 99 ? "99+" : unreadFeedbackCount}
</span>
)}
</TabsTrigger>
<TabsTrigger value="keycloak" className="gap-1.5">
Keycloak
{!!kcErrorCount && (
<span className="inline-flex items-center justify-center rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-bold leading-none text-white">
{kcErrorCount > 99 ? "99+" : kcErrorCount}
</span>
)}
</TabsTrigger>
<TabsTrigger value="user-guide">User Guide</TabsTrigger>
</TabsList>
@@ -1524,6 +1541,9 @@ export default function AdminPage() {
<SettingRow label="Auto-Archive Expired Items" description="Archive expired licenses/certs after this many days. 0 = disabled" htmlFor="auto-archive">
<Input id="auto-archive" type="number" min={0} max={3650} className="w-24 h-8 text-sm" value={sf.form.auto_archive_days ?? ""} onChange={(e) => sf.updateField("auto_archive_days", e.target.value)} />
</SettingRow>
<SettingRow label="KC Inbound Sync" description="Seconds between pulling users and permissions from Keycloak" htmlFor="kc-inbound-sync">
<Input id="kc-inbound-sync" type="number" min={5} max={86400} className="w-24 h-8 text-sm" value={sf.form.kc_inbound_sync_seconds ?? ""} onChange={(e) => sf.updateField("kc_inbound_sync_seconds", e.target.value)} placeholder="30" />
</SettingRow>
</CardContent>
</Card>