Compare commits

...

7 Commits

Author SHA1 Message Date
b919c9060c Add admin settings, SMTP email notifications, Keycloak seed script
- Add 9 admin settings (default rate, session timeout, audit retention,
  default billing type, PKCS12 passphrase, upload limit, auto-archive,
  email notifications) with backend key-value store and frontend UI
- Add granular email alert types (license/cert expiring/expired, new
  feedback, user changes) with per-type toggles and multiple recipients
- Add SMTP configuration via env vars (SMTP_HOST, SMTP_PORT, etc.)
  with status display, test email endpoint, and Helm chart support
- Wire PKCS12 passphrase setting to cert export
- Add database backup download endpoint
- Add Keycloak seed script (scripts/seed-keycloak.py) and update docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:22:12 -07:00
480c8f026b Fix relative timestamps showing future times instead of past
Append "Z" suffix to all UTC datetime isoformat() serialization so
date-fns parseISO interprets them as UTC rather than local time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:13:05 -07:00
71923b1ef2 Add Keycloak setup guide for local, Docker Compose, and Helm
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:07:13 -07:00
a57960df02 Update CLAUDE.md with users, feedback, settings, and admin features
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:03:38 -07:00
616977f304 Add Keycloak diagnostics panel with Cloudflare-style connection chain
Backend endpoint runs sequential DNS, TCP, TLS, auth, and realm checks
against the configured Keycloak instance. Frontend displays results as
a visual connection chain with colored status indicators and error details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:00:58 -07:00
57ead2087c Fix TypeScript build errors for Docker: unused imports, type mismatches
- Remove unused BillingBadge/PrivilegeBadge functions from user-detail
- Use ProjectMembership type instead of typeof user.projects for narrowing
- Add onboarding_projects and freeloaders to useDashboardStats return type
- Remove unused KeycloakStatus type and CheckIcon/MinusCircle from admin page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:59:10 -07:00
79e71f0244 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>
2026-03-11 16:47:32 -07:00
26 changed files with 2515 additions and 141 deletions

View File

@@ -2,36 +2,44 @@
## Project Overview
Internal management tool with three domains: Projects, Licenses, and Certificates. Monorepo with a Flask backend and React frontend, deployed to Kubernetes via Helm.
Internal management tool with four domains: Projects, Users, Licenses, and Certificates. Includes a feedback system and admin panel. Monorepo with a Flask backend and React frontend, deployed to Kubernetes via Helm.
## Repository Structure
```
backend/ Python Flask API
app/
auth/ Session-based auth (login_required decorator)
projects/ Project CRUD with row-level filtering stub
auth/ Session-based auth (login_required decorator, is_admin check)
projects/ Project CRUD with row-level filtering stub, user membership management
users/ User CRUD, ProjectUser join model with billing types and privileges
licenses/ License CRUD with file upload/download
certs/ Cert CRUD with encrypted key storage, format conversion
dashboard/ Stats, expiring items, audit log
dashboard/ Stats, expiring items, audit log (limited to 10 most recent)
feedback/ Feedback CRUD with screenshot storage and status workflow
settings/ App-level settings (expiry thresholds) with key-value store
keycloak.py Keycloak SSO integration (diagnostics endpoint)
crypto.py Fernet encrypt/decrypt helpers
audit.py AuditLog model + log_audit() helper
config.py All config via env vars
run.py Entry point
run.py Entry point (db.create_all() on startup, no migrations dir)
frontend/ React + TypeScript
src/
pages/ One file per route (login, dashboard, projects-list, etc.)
pages/ One file per route (login, dashboard, projects-list, project-detail,
users-list, user-detail, licenses-list, certs-list, admin)
components/
ui/ shadcn/ui primitives (do not edit manually)
shared/ Reusable components (data-table, stat-card, status-badge, etc.)
shared/ Reusable components (data-table, stat-card, status-badge, activity-feed, etc.)
projects/ Project-specific components
users/ User-specific components (columns, form)
licenses/ License-specific components
certs/ Cert-specific components
dashboard/ Dashboard widgets
layout/ App shell, sidebar, topbar, protected-route
hooks/ React Query hooks (use-projects, use-licenses, use-certs)
contexts/ Auth context provider
dashboard/ Dashboard widgets (stats-overview, expiring-items, recent-activity)
feedback/ Feedback modal (screenshot attach with auto-scaling)
layout/ App shell, sidebar (with admin nav), topbar, protected-route
hooks/ React Query hooks (use-projects, use-users, use-project-users,
use-licenses, use-certs, use-feedback, use-settings)
contexts/ Auth context provider (includes is_admin flag)
lib/ API client (axios), utils, constants
chart/osa-suite/ Helm chart for Kubernetes deployment
@@ -66,15 +74,19 @@ cd frontend && npm run build # Production build (also runs tsc)
## Architecture Conventions
### Backend
- Flask blueprints: one per domain (auth, projects, licenses, certs, dashboard)
- Flask blueprints: one per domain (auth, projects, users, licenses, certs, dashboard, feedback, settings)
- All API routes prefixed with `/api/`
- All list endpoints return wrapped objects: `{"projects": [...]}`, `{"licenses": [...]}`, etc.
- Single-item endpoints return: `{"project": {...}}`, etc.
- All list endpoints return wrapped objects: `{"projects": [...]}`, `{"licenses": [...]}`, `{"feedback": [...]}`, etc.
- Single-item endpoints return: `{"project": {...}}`, `{"feedback": {...}}`, etc.
- `@login_required` decorator on all routes except POST /api/auth/login
- `/api/auth/login` and `/api/auth/me` return `is_admin` flag (true for VELA project admins or hardcoded admin user)
- Models use SQLAlchemy portable types only (no SQLite-specific features) for Postgres compatibility
- No migrations directory — uses `db.create_all()` on startup with manual ALTER TABLE for schema changes in `run.py`
- Audit logging via `log_audit()` on all CRUD and export operations
- Cert private keys encrypted at rest with Fernet; cert PEM stored unencrypted (public data)
- License and cert status computed via hybrid properties, not stored columns
- Feedback screenshots stored as JSON array of base64 strings in the database
- Settings stored as key-value pairs via the Setting model (e.g., expiry thresholds)
### Frontend
- React Query (TanStack Query) for all server state — no Redux/Zustand
@@ -84,7 +96,11 @@ cd frontend && npm run build # Production build (also runs tsc)
- Axios client in `lib/api.ts` with `withCredentials: true` and 401 interceptor
- Vite proxies `/api` to backend in dev; nginx proxies in production
- Toast notifications (sonner) for all mutations
- Status badges color-coded: green (active/valid), amber (expiring_soon), red (expired), indigo (perpetual)
- Status badges color-coded: green (active/valid), amber (expiring_soon), red (expired), indigo (perpetual), blue (onboarding)
- Feedback status badges: blue (unread), gray (read), amber (assigned), green (addressed)
- Admin sidebar link (Settings icon) visible only when `user.is_admin` is true
- Shared `ActivityFeed` component used on project detail and user detail pages
- Project memberships editable inline via Select dropdowns on both project detail and user detail pages
### Naming
- Backend: snake_case for Python, kebab-case for URL paths
@@ -94,15 +110,18 @@ cd frontend && npm run build # Production build (also runs tsc)
## Key Design Decisions
- **SQLite now, Postgres later** — just change `DATABASE_URL`. All types are portable.
- **Single admin user for MVP** — `config.py` stores hashed password. Keycloak SSO planned.
- **Single admin user for MVP** — `config.py` stores hashed password. Keycloak SSO planned (diagnostics endpoint available).
- **Admin access** — determined by VELA project admin privilege or hardcoded admin username. Controls sidebar admin link visibility.
- **Row-level filtering** — stubbed via `Project.for_user()`, returns all for now.
- **Feedback system** — users submit feedback from project detail pages with optional screenshots (auto-scaled to 1200x900 max). Admins manage via admin page with status workflow (unread → read → assigned → addressed).
- **Configurable expiry thresholds** — "expiring soon" window for licenses and certs is configurable via admin settings (stored in settings table), not hardcoded.
- **Cert encryption** — Fernet key persists to `.fernet_key` file in dev, env var in production. Changing the key makes existing encrypted data unrecoverable.
- **PKCS12 exported without passphrase** — explicit product decision.
- **Backend replicas must stay at 1** while using SQLite (no concurrent writes). Scale freely after Postgres migration.
## Stubs (Not Yet Implemented)
- `components/projects/keycloak-stub.tsx` — Keycloak user/group management
- `components/projects/keycloak-stub.tsx` — Keycloak user/group management (diagnostics available on admin page)
- `components/certs/aws-secrets-stub.tsx` — AWS Secrets Manager sync
- `Project.for_user()` in `backend/app/projects/models.py` — row-level access control

View File

@@ -63,8 +63,8 @@ class AuditLog(db.Model):
"user": self.user,
"details": json.loads(self.details) if self.details else None,
"ip_address": self.ip_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"timestamp": self.created_at.isoformat() if self.created_at else None,
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
"timestamp": (self.created_at.isoformat() + "Z") if self.created_at else None,
}

View File

@@ -206,14 +206,19 @@ def export_der(cert):
return cert.public_bytes(Encoding.DER)
def export_pkcs12(cert, key=None, chain=None, friendly_name=b"certificate"):
"""Export as PKCS12 bundle with no passphrase."""
def export_pkcs12(cert, key=None, chain=None, friendly_name=b"certificate", passphrase=None):
"""Export as PKCS12 bundle, optionally with a passphrase."""
if passphrase:
from cryptography.hazmat.primitives.serialization import BestAvailableEncryption
encryption = BestAvailableEncryption(passphrase if isinstance(passphrase, bytes) else passphrase.encode())
else:
encryption = NoEncryption()
return pkcs12.serialize_key_and_certificates(
friendly_name,
key,
cert,
chain,
NoEncryption(),
encryption,
)

View File

@@ -86,8 +86,8 @@ class Cert(db.Model):
"status": self.status,
"has_private_key": self.private_key_encrypted is not None,
"has_chain": self.chain_pem is not None and len(self.chain_pem.strip()) > 0,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
}
if include_private_key:
d["cert_pem"] = self.cert_pem

View File

@@ -329,7 +329,11 @@ def export_cert(cert_id):
elif fmt == "pkcs12":
try:
data = export_pkcs12(cert_obj, key_obj, chain_certs or None)
from app.settings.models import Setting
passphrase = None
if Setting.get_bool("pkcs12_passphrase_required"):
passphrase = "changeit" # Standard PKCS12 passphrase
data = export_pkcs12(cert_obj, key_obj, chain_certs or None, passphrase=passphrase)
except Exception as e:
logger.error("PKCS12 export failed for cert %d: %s", cert_id, e)
return jsonify({"error": "PKCS12 export failed."}), 500

61
backend/app/email.py Normal file
View File

@@ -0,0 +1,61 @@
import logging
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from flask import current_app
logger = logging.getLogger(__name__)
def is_smtp_configured():
"""Check whether SMTP env vars are set."""
return bool(current_app.config.get("SMTP_HOST"))
def send_email(to_addresses, subject, body_text, body_html=None):
"""Send an email via SMTP.
Args:
to_addresses: list of recipient email strings
subject: email subject
body_text: plain-text body
body_html: optional HTML body
Returns:
True on success, raises on failure.
"""
host = current_app.config["SMTP_HOST"]
port = current_app.config["SMTP_PORT"]
username = current_app.config["SMTP_USERNAME"]
password = current_app.config["SMTP_PASSWORD"]
use_tls = current_app.config["SMTP_USE_TLS"]
from_addr = current_app.config["SMTP_FROM_ADDRESS"]
if not host:
raise RuntimeError("SMTP is not configured (SMTP_HOST not set)")
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_addr
msg["To"] = ", ".join(to_addresses)
msg.attach(MIMEText(body_text, "plain"))
if body_html:
msg.attach(MIMEText(body_html, "html"))
if use_tls:
server = smtplib.SMTP(host, port, timeout=10)
server.starttls()
else:
server = smtplib.SMTP(host, port, timeout=10)
try:
if username:
server.login(username, password)
server.sendmail(from_addr, to_addresses, msg.as_string())
finally:
server.quit()
logger.info("Email sent to %s: %s", to_addresses, subject)
return True

View File

@@ -31,8 +31,8 @@ class Feedback(db.Model):
"username": self.username,
"message": self.message,
"status": self.status,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
}
if include_screenshots:
import json

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

@@ -72,8 +72,8 @@ class License(db.Model):
"project_id": self.project_id,
"archived": self.archived,
"status": self.status,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
}
if include_file_info:
d["file_name"] = self.file_name

View File

@@ -54,9 +54,9 @@ class Project(db.Model):
"admin_email": self.admin_email,
"rate": float(self.rate) if self.rate is not None else None,
"status": self.status,
"archived_at": self.archived_at.isoformat() if self.archived_at else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"archived_at": (self.archived_at.isoformat() + "Z") if self.archived_at else None,
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
}
if include_counts:
d["license_count"] = self.licenses.count()

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

@@ -11,6 +11,21 @@ class Setting(db.Model):
DEFAULTS = {
"license_expiry_threshold_days": "30",
"cert_expiry_threshold_days": "30",
"default_project_rate": "300",
"session_timeout_hours": "8",
"audit_log_retention_days": "0", # 0 = keep forever
"email_notifications_enabled": "false",
"email_notifications_address": "",
"notify_license_expiring": "true",
"notify_cert_expiring": "true",
"notify_license_expired": "true",
"notify_cert_expired": "true",
"notify_new_feedback": "false",
"notify_user_changes": "false",
"default_billing_type": "core",
"pkcs12_passphrase_required": "false",
"max_upload_size_mb": "50",
"auto_archive_days": "0", # 0 = disabled
}
@classmethod
@@ -27,6 +42,12 @@ class Setting(db.Model):
val = cls.get(key)
return int(val) if val is not None else None
@classmethod
def get_bool(cls, key):
"""Get a setting value as a boolean."""
val = cls.get(key)
return val is not None and val.lower() in ("true", "1", "yes")
@classmethod
def set(cls, key, value):
"""Set a setting value (upsert)."""

View File

@@ -1,9 +1,18 @@
from flask import jsonify, request
import logging
import os
import socket
import ssl
import time
from urllib.parse import urlparse
from flask import jsonify, request, current_app, send_file
from app.auth.decorators import login_required
from app.settings import settings_bp
from app.settings.models import Setting
logger = logging.getLogger(__name__)
@settings_bp.route("/expiry-thresholds", methods=["GET"])
@login_required
@@ -30,3 +39,309 @@ def update_expiry_thresholds():
"license_expiry_threshold_days": Setting.get_int("license_expiry_threshold_days"),
"cert_expiry_threshold_days": Setting.get_int("cert_expiry_threshold_days"),
}), 200
@settings_bp.route("/all", methods=["GET"])
@login_required
def get_all_settings():
result = {}
for key in Setting.DEFAULTS:
result[key] = Setting.get(key)
return jsonify(result), 200
@settings_bp.route("/all", methods=["PUT"])
@login_required
def update_all_settings():
data = request.get_json() or {}
VALIDATORS = {
"default_project_rate": lambda v: isinstance(v, (int, float)) and v >= 0,
"session_timeout_hours": lambda v: isinstance(v, (int, float)) and 1 <= v <= 168,
"audit_log_retention_days": lambda v: isinstance(v, int) and 0 <= v <= 3650,
"email_notifications_enabled": lambda v: isinstance(v, bool),
"email_notifications_address": lambda v: isinstance(v, str) and len(v) <= 500,
"notify_license_expiring": lambda v: isinstance(v, bool),
"notify_cert_expiring": lambda v: isinstance(v, bool),
"notify_license_expired": lambda v: isinstance(v, bool),
"notify_cert_expired": lambda v: isinstance(v, bool),
"notify_new_feedback": lambda v: isinstance(v, bool),
"notify_user_changes": lambda v: isinstance(v, bool),
"default_billing_type": lambda v: v in ("core", "collaborator", "standing"),
"pkcs12_passphrase_required": lambda v: isinstance(v, bool),
"max_upload_size_mb": lambda v: isinstance(v, int) and 1 <= v <= 500,
"auto_archive_days": lambda v: isinstance(v, int) and 0 <= v <= 3650,
}
for key, value in data.items():
if key not in Setting.DEFAULTS:
continue
if key in VALIDATORS and not VALIDATORS[key](value):
return jsonify({"error": f"Invalid value for {key}"}), 400
# Convert booleans to string
if isinstance(value, bool):
Setting.set(key, "true" if value else "false")
else:
Setting.set(key, value)
# Return all settings
result = {}
for key in Setting.DEFAULTS:
result[key] = Setting.get(key)
return jsonify(result), 200
@settings_bp.route("/backup", methods=["GET"])
@login_required
def download_backup():
"""Download a copy of the SQLite database."""
db_uri = current_app.config.get("SQLALCHEMY_DATABASE_URI", "")
if not db_uri.startswith("sqlite"):
return jsonify({"error": "Backup only available for SQLite databases"}), 400
# Extract path from sqlite:///path or sqlite:////abs/path
db_path = db_uri.replace("sqlite:///", "", 1)
if not os.path.isabs(db_path):
db_path = os.path.join(current_app.instance_path, db_path)
if not os.path.exists(db_path):
return jsonify({"error": "Database file not found"}), 404
from app.audit import log_audit
log_audit("system", None, "database_backup")
from datetime import date
filename = f"osa-backup-{date.today().isoformat()}.db"
return send_file(db_path, as_attachment=True, download_name=filename)
# ── SMTP ─────────────────────────────────────────────────────
@settings_bp.route("/smtp-status", methods=["GET"])
@login_required
def smtp_status():
"""Return SMTP configuration status (no secrets)."""
host = current_app.config.get("SMTP_HOST", "")
return jsonify({
"configured": bool(host),
"host": host,
"port": current_app.config.get("SMTP_PORT", 587),
"use_tls": current_app.config.get("SMTP_USE_TLS", True),
"from_address": current_app.config.get("SMTP_FROM_ADDRESS", ""),
"has_credentials": bool(current_app.config.get("SMTP_USERNAME")),
}), 200
@settings_bp.route("/test-email", methods=["POST"])
@login_required
def test_email():
"""Send a test email to verify SMTP configuration."""
data = request.get_json() or {}
to = data.get("to", "").strip()
if not to:
return jsonify({"error": "Recipient email address is required"}), 400
from app.email import is_smtp_configured, send_email
if not is_smtp_configured():
return jsonify({"error": "SMTP is not configured. Set SMTP_HOST environment variable."}), 400
try:
send_email(
[to],
"OSA Suite — Test Email",
"This is a test email from OSA Suite. If you received this, SMTP is configured correctly.",
"<p>This is a test email from <strong>OSA Suite</strong>.</p>"
"<p>If you received this, SMTP is configured correctly.</p>",
)
return jsonify({"message": f"Test email sent to {to}"}), 200
except Exception as exc:
logger.error("Test email failed: %s", exc)
return jsonify({"error": str(exc)}), 500
# ── Keycloak diagnostics ──────────────────────────────────────
def _check_dns(hostname):
"""Resolve hostname to IP addresses."""
start = time.time()
try:
addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
ips = list({addr[4][0] for addr in addrs})
elapsed = round((time.time() - start) * 1000)
return {"status": "ok", "ips": ips, "ms": elapsed}
except socket.gaierror as exc:
elapsed = round((time.time() - start) * 1000)
return {"status": "error", "error": str(exc), "ms": elapsed}
def _check_tcp(hostname, port):
"""Test TCP connectivity."""
start = time.time()
try:
sock = socket.create_connection((hostname, port), timeout=5)
sock.close()
elapsed = round((time.time() - start) * 1000)
return {"status": "ok", "ms": elapsed}
except (socket.timeout, OSError) as exc:
elapsed = round((time.time() - start) * 1000)
return {"status": "error", "error": str(exc), "ms": elapsed}
def _check_tls(hostname, port):
"""Validate TLS certificate."""
start = time.time()
try:
ctx = ssl.create_default_context()
with socket.create_connection((hostname, port), timeout=5) as sock:
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
elapsed = round((time.time() - start) * 1000)
return {
"status": "ok",
"subject": dict(x[0] for x in cert.get("subject", ())),
"issuer": dict(x[0] for x in cert.get("issuer", ())),
"expires": cert.get("notAfter"),
"ms": elapsed,
}
except (ssl.SSLError, OSError) as exc:
elapsed = round((time.time() - start) * 1000)
return {"status": "error", "error": str(exc), "ms": elapsed}
def _check_auth(keycloak_url, realm, client_id, client_secret):
"""Attempt service-account token acquisition."""
import requests as req
start = time.time()
token_url = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
try:
resp = req.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
timeout=10,
)
elapsed = round((time.time() - start) * 1000)
if resp.status_code == 200:
data = resp.json()
return {
"status": "ok",
"token_type": data.get("token_type"),
"expires_in": data.get("expires_in"),
"ms": elapsed,
}
return {
"status": "error",
"error": f"HTTP {resp.status_code}",
"detail": resp.text[:200],
"ms": elapsed,
}
except Exception as exc:
elapsed = round((time.time() - start) * 1000)
return {"status": "error", "error": str(exc), "ms": elapsed}
def _check_realm(keycloak_url, realm, token):
"""Verify admin API access by hitting the users endpoint."""
import requests as req
start = time.time()
try:
resp = req.get(
f"{keycloak_url}/admin/realms/{realm}/users",
params={"max": 1},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
elapsed = round((time.time() - start) * 1000)
if resp.status_code == 200:
return {"status": "ok", "ms": elapsed}
return {
"status": "error",
"error": f"HTTP {resp.status_code}",
"detail": resp.text[:200],
"ms": elapsed,
}
except Exception as exc:
elapsed = round((time.time() - start) * 1000)
return {"status": "error", "error": str(exc), "ms": elapsed}
@settings_bp.route("/keycloak-status", methods=["GET"])
@login_required
def keycloak_status():
kc_url = current_app.config.get("KEYCLOAK_URL", "")
realm = current_app.config.get("KEYCLOAK_REALM", "")
client_id = current_app.config.get("KEYCLOAK_CLIENT_ID", "")
client_secret = current_app.config.get("KEYCLOAK_CLIENT_SECRET", "")
if not kc_url:
return jsonify({
"enabled": False,
"message": "Keycloak is not configured (KEYCLOAK_URL not set)",
}), 200
parsed = urlparse(kc_url)
hostname = parsed.hostname
port = parsed.port or (443 if parsed.scheme == "https" else 80)
is_https = parsed.scheme == "https"
checks = {}
# 1. DNS
checks["dns"] = _check_dns(hostname)
# 2. TCP — only if DNS passed
if checks["dns"]["status"] == "ok":
checks["tcp"] = _check_tcp(hostname, port)
else:
checks["tcp"] = {"status": "skipped", "reason": "DNS failed"}
# 3. TLS — only if TCP passed and using HTTPS
if not is_https:
checks["tls"] = {"status": "skipped", "reason": "Not using HTTPS"}
elif checks["tcp"]["status"] == "ok":
checks["tls"] = _check_tls(hostname, port)
else:
checks["tls"] = {"status": "skipped", "reason": "TCP failed"}
# 4. Auth — only if connectivity is good
tcp_ok = checks["tcp"]["status"] == "ok"
tls_ok = checks.get("tls", {}).get("status") in ("ok", "skipped")
if tcp_ok and tls_ok:
checks["auth"] = _check_auth(kc_url, realm, client_id, client_secret)
else:
checks["auth"] = {"status": "skipped", "reason": "Connectivity failed"}
# 5. Realm — only if auth passed
if checks["auth"]["status"] == "ok":
import requests as req
token_url = f"{kc_url}/realms/{realm}/protocol/openid-connect/token"
try:
tresp = req.post(token_url, data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
}, timeout=10)
token = tresp.json().get("access_token", "")
except Exception:
token = ""
checks["realm"] = _check_realm(kc_url, realm, token)
else:
checks["realm"] = {"status": "skipped", "reason": "Auth failed"}
all_ok = all(
c.get("status") in ("ok", "skipped")
for c in checks.values()
)
return jsonify({
"enabled": True,
"url": kc_url,
"realm": realm,
"overall": "ok" if all_ok else "error",
"checks": checks,
}), 200

View File

@@ -30,8 +30,8 @@ class User(db.Model):
"email": self.email,
"active": self.active,
"keycloak_id": self.keycloak_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
}
if include_projects:
d["projects"] = [m.to_dict() for m in self.project_memberships]
@@ -78,6 +78,6 @@ class ProjectUser(db.Model):
"user_email": self.user.email if self.user else None,
"billing_type": self.billing_type,
"privileges": self.privileges,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_at": (self.created_at.isoformat() + "Z") if self.created_at else None,
"updated_at": (self.updated_at.isoformat() + "Z") if self.updated_at else None,
}

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,20 @@ class Config:
# Upload limit
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
# SMTP (disabled when SMTP_HOST is empty)
SMTP_HOST = os.environ.get("SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "")
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
SMTP_FROM_ADDRESS = os.environ.get("SMTP_FROM_ADDRESS", "osa@example.com")
# 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

@@ -12,3 +12,11 @@ stringData:
{{- if .Values.auth.passwordHash }}
AUTH_PASSWORD_HASH: {{ .Values.auth.passwordHash | quote }}
{{- end }}
{{- if .Values.smtp.host }}
SMTP_HOST: {{ .Values.smtp.host | quote }}
SMTP_PORT: {{ .Values.smtp.port | default 587 | quote }}
SMTP_USERNAME: {{ .Values.smtp.username | quote }}
SMTP_PASSWORD: {{ .Values.smtp.password | quote }}
SMTP_USE_TLS: {{ .Values.smtp.useTls | default true | quote }}
SMTP_FROM_ADDRESS: {{ .Values.smtp.fromAddress | default "osa@example.com" | quote }}
{{- end }}

View File

@@ -46,6 +46,14 @@ secrets:
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
fernetKey: ""
smtp:
host: ""
port: 587
username: ""
password: ""
useTls: true
fromAddress: "osa@example.com"
ingress:
enabled: false
className: ""

182
docs/keycloak-setup.md Normal file
View File

@@ -0,0 +1,182 @@
# Keycloak Setup Guide
OSA Suite integrates with Keycloak for SSO user/group management. Keycloak is **optional** — the app runs fine without it. When `KEYCLOAK_URL` is not set, all Keycloak operations are silently skipped.
## Keycloak Requirements
OSA Suite uses a **service-account client** with Admin API access. You need:
1. A Keycloak realm (default: `osa`)
2. A client with **Service Accounts Enabled** and the `realm-admin` role
3. The client ID and secret
### Creating the Client in Keycloak
1. Go to your realm > **Clients** > **Create client**
2. Set **Client ID** to `osa-admin-client`
3. Set **Client authentication** to **On**
4. Under **Service account roles**, enable **Service accounts roles**
5. Save, then go to the **Service account roles** tab
6. Click **Assign role** > filter by clients > assign `realm-management` > `realm-admin`
7. Go to the **Credentials** tab and copy the **Client secret**
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `KEYCLOAK_URL` | *(empty — disabled)* | Base URL, e.g. `https://keycloak.example.com` |
| `KEYCLOAK_REALM` | `osa` | Realm name |
| `KEYCLOAK_CLIENT_ID` | `osa-admin-client` | Service-account client ID |
| `KEYCLOAK_CLIENT_SECRET` | *(empty)* | Service-account client secret |
## Local Development
Add the variables to `backend/.env`:
```bash
# backend/.env
KEYCLOAK_URL=http://localhost:8180
KEYCLOAK_REALM=osa
KEYCLOAK_CLIENT_ID=osa-admin-client
KEYCLOAK_CLIENT_SECRET=your-client-secret
```
To run a local Keycloak instance for testing:
```bash
docker run -d --name keycloak \
-p 8180:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.2 start-dev
```
Then open `http://localhost:8180`, log in as `admin`/`admin`, create the `osa` realm and service-account client as described above.
Start the app normally:
```bash
./dev.sh
```
Check the **Admin > Connection Status** panel to verify connectivity.
### Seeding Sample Data
A standalone script populates Keycloak with the same seed data used by the app (users, groups for each project, and memberships):
```bash
# Local Keycloak with defaults (localhost:8180, admin/admin)
python scripts/seed-keycloak.py
# Custom instance
python scripts/seed-keycloak.py \
--url https://keycloak.example.com \
--admin-user admin \
--admin-password changeme
# Env vars work too
KEYCLOAK_URL=http://keycloak:8080 python scripts/seed-keycloak.py
```
The script creates the realm, a service-account client (`osa-admin-client` with `realm-admin` role), all users, project groups, and group memberships. It prints the connection env vars at the end.
## Docker Compose
Add the Keycloak env vars to the backend service and optionally add a Keycloak container:
```yaml
services:
keycloak:
image: quay.io/keycloak/keycloak:26.2
container_name: keycloak
command: start-dev
ports:
- "8180:8080"
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: osa-backend
ports:
- "5001:5001"
environment:
- FLASK_ENV=development
- DATABASE_URL=sqlite:////app/osa.db
- KEYCLOAK_URL=http://keycloak:8080
- KEYCLOAK_REALM=osa
- KEYCLOAK_CLIENT_ID=osa-admin-client
- KEYCLOAK_CLIENT_SECRET=your-client-secret
depends_on:
- keycloak
restart: unless-stopped
frontend:
# ... (unchanged)
```
Note: inside the Docker network, the backend reaches Keycloak at `http://keycloak:8080` (container name, internal port), not `localhost:8180`.
## Helm (Kubernetes)
### 1. Add Keycloak values
Add the following to your `values.yaml` or pass via `--set`:
```yaml
keycloak:
url: "https://keycloak.example.com"
realm: "osa"
clientId: "osa-admin-client"
clientSecret: "your-client-secret"
```
### 2. Add to the secret template
In `chart/osa-suite/templates/secret.yaml`, add the Keycloak fields:
```yaml
stringData:
# ... existing keys ...
{{- if .Values.keycloak.url }}
KEYCLOAK_URL: {{ .Values.keycloak.url | quote }}
KEYCLOAK_REALM: {{ .Values.keycloak.realm | default "osa" | quote }}
KEYCLOAK_CLIENT_ID: {{ .Values.keycloak.clientId | default "osa-admin-client" | quote }}
KEYCLOAK_CLIENT_SECRET: {{ .Values.keycloak.clientSecret | quote }}
{{- end }}
```
### 3. Deploy
```bash
helm install osa ./chart/osa-suite \
--set secrets.fernetKey=... \
--set secrets.secretKey=... \
--set keycloak.url=https://keycloak.example.com \
--set keycloak.clientSecret=your-client-secret
```
Or to deploy without Keycloak (default), just omit the `keycloak.*` values.
## Verifying the Connection
Once configured, go to the **Admin** page in OSA Suite. The **Connection Status** panel runs a diagnostic chain:
1. **DNS** — resolves the Keycloak hostname
2. **TCP** — connects to the port
3. **TLS** — validates the certificate (if HTTPS)
4. **Auth** — obtains a service-account token
5. **Realm** — calls the Admin API to verify access
Each step shows pass/fail with latency. If a step fails, the error message and details are shown below the chain.
## How the Integration Works
- **Users**: when a user is created/updated in OSA Suite, a corresponding Keycloak user is created/updated (matched by email). The Keycloak user ID is stored in the `keycloak_id` column.
- **Groups**: Keycloak groups map to OSA projects. When a user is added to a project, they are added to the corresponding Keycloak group.
- **Disabled mode**: when `KEYCLOAK_URL` is empty, all Keycloak client methods return `None` immediately. No HTTP calls are made. The app works identically, just without SSO sync.

View File

@@ -2,25 +2,25 @@
## UI changes
- [ ] Remove Attention needed card on dashboard
- [ ] Add an Archived state for certs/licenses (not just expired)
- [ ] Add expired in card for licenses and certs on Dashboard
- [ ] Projects overview page- 1 column for people, make their names pills
- [ ] fix tooltip email copy functionality
- [ ] more padding on right edge for at a glance
- [ ] cards within cards like screenshot
- [ x ] Remove Attention needed card on dashboard
- [ x ] Add an Archived state for certs/licenses (not just expired)
- [ x ] Add expired in card for licenses and certs on Dashboard
- [ x ] Projects overview page- 1 column for people, make their names pills
- [ x ] fix tooltip email copy functionality
- [ x ] more padding on right edge for at a glance
- [ x ] cards within cards like screenshot
## New work
- [ ] add concept of paid, unpaid, non-billable user for that project
- [ x ] add concept of paid, unpaid, non-billable user for that project
core users, collaborators, standing
- [ ] change from core to collaborator
- [ x ] change from core to collaborator
first check if someone else is paying for them
or flag as standing
- [ ] new Users page
- [ ] Shows all groups person is attached to
- [ ] show history of project movement/permissions modified
- [ ] projects should be clickable
- [ x ] new Users page
- [ x ] Shows all groups person is attached to
- [ x ] show history of project movement/permissions modified
- [ x ] projects should be clickable
## Integrations

View File

@@ -158,10 +158,12 @@ export function useDashboardStats() {
const res = await api.get("/dashboard/stats");
return res.data as {
projects_count: number;
onboarding_projects: number;
total_users: number;
active_users: number;
licenses_count: number;
certs_count: number;
freeloaders: number;
expiring_licenses_30d: number;
expiring_certs_30d: number;
expired_licenses: number;

View File

@@ -16,6 +16,118 @@ export function useExpiryThresholds() {
});
}
interface KeycloakCheck {
status: "ok" | "error" | "skipped";
ms?: number;
error?: string;
detail?: string;
reason?: string;
ips?: string[];
subject?: Record<string, string>;
issuer?: Record<string, string>;
expires?: string;
token_type?: string;
expires_in?: number;
}
export interface KeycloakStatus {
enabled: boolean;
message?: string;
url?: string;
realm?: string;
overall?: "ok" | "error";
checks?: {
dns: KeycloakCheck;
tcp: KeycloakCheck;
tls: KeycloakCheck;
auth: KeycloakCheck;
realm: KeycloakCheck;
};
}
export function useKeycloakStatus() {
return useQuery<KeycloakStatus>({
queryKey: ["settings", "keycloak-status"],
queryFn: async () => {
const { data } = await api.get("/settings/keycloak-status");
return data;
},
refetchOnWindowFocus: false,
});
}
interface AllSettings {
default_project_rate: string;
session_timeout_hours: string;
audit_log_retention_days: string;
email_notifications_enabled: string;
email_notifications_address: string;
notify_license_expiring: string;
notify_cert_expiring: string;
notify_license_expired: string;
notify_cert_expired: string;
notify_new_feedback: string;
notify_user_changes: string;
default_billing_type: string;
pkcs12_passphrase_required: string;
max_upload_size_mb: string;
auto_archive_days: string;
license_expiry_threshold_days: string;
cert_expiry_threshold_days: string;
}
export function useAllSettings() {
return useQuery<AllSettings>({
queryKey: ["settings", "all"],
queryFn: async () => {
const { data } = await api.get("/settings/all");
return data;
},
});
}
export function useUpdateSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (settings: Record<string, unknown>) => {
const { data } = await api.put("/settings/all", settings);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
}
export interface SmtpStatus {
configured: boolean;
host: string;
port: number;
use_tls: boolean;
from_address: string;
has_credentials: boolean;
}
export function useSmtpStatus() {
return useQuery<SmtpStatus>({
queryKey: ["settings", "smtp-status"],
queryFn: async () => {
const { data } = await api.get("/settings/smtp-status");
return data;
},
refetchOnWindowFocus: false,
});
}
export function useSendTestEmail() {
return useMutation({
mutationFn: async (to: string) => {
const { data } = await api.post("/settings/test-email", { to });
return data;
},
});
}
export function useUpdateExpiryThresholds() {
const queryClient = useQueryClient();
return useMutation({

View File

@@ -1,9 +1,24 @@
import { useState, useEffect } from "react";
import { format, parseISO } from "date-fns";
import { Settings } from "lucide-react";
import {
Settings,
Shield,
Globe,
Plug,
Lock,
KeyRound,
Database,
CheckCircle2,
XCircle,
RefreshCw,
Loader2,
Mail,
Send,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Table,
TableBody,
@@ -39,6 +54,11 @@ import {
import {
useExpiryThresholds,
useUpdateExpiryThresholds,
useKeycloakStatus,
useAllSettings,
useUpdateSettings,
useSmtpStatus,
useSendTestEmail,
} from "@/hooks/use-settings";
import {
FEEDBACK_STATUS_COLORS,
@@ -151,6 +171,541 @@ function ExpiryThresholdsSection() {
);
}
const CHECK_STEPS = [
{ key: "dns", label: "DNS", sublabel: "Resolution", icon: Globe },
{ key: "tcp", label: "TCP", sublabel: "Connection", icon: Plug },
{ key: "tls", label: "TLS", sublabel: "Certificate", icon: Lock },
{ key: "auth", label: "Auth", sublabel: "Credentials", icon: KeyRound },
{ key: "realm", label: "Realm", sublabel: "Admin API", icon: Database },
] as const;
function KeycloakDiagnosticsSection() {
const { data, isLoading, refetch, isFetching } = useKeycloakStatus();
if (isLoading) {
return (
<Card>
<CardContent className="py-6">
<Skeleton className="h-5 w-full" />
</CardContent>
</Card>
);
}
if (!data?.enabled) {
return (
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">
Connection Status
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Keycloak is not configured. Set <code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">KEYCLOAK_URL</code> to enable.
</p>
</CardContent>
</Card>
);
}
const checks = data.checks!;
return (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base font-semibold">
Connection Status
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{data.url} &mdash; realm <span className="font-mono">{data.realm}</span>
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 size-3.5" />
)}
Re-check
</Button>
</div>
</CardHeader>
<CardContent>
{/* Connection chain */}
<div className="flex items-center gap-0">
{CHECK_STEPS.map((step, i) => {
const check = checks[step.key];
const isOk = check.status === "ok";
const isErr = check.status === "error";
return (
<div key={step.key} className="flex items-center">
{/* Step */}
<div className="flex flex-col items-center">
<div
className={cn(
"flex size-12 items-center justify-center rounded-full border-2",
isOk && "border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30",
isErr && "border-red-500 bg-red-50 dark:bg-red-950/30",
!isOk && !isErr && "border-border bg-muted/50"
)}
>
<step.icon
className={cn(
"size-5",
isOk && "text-emerald-600 dark:text-emerald-400",
isErr && "text-red-600 dark:text-red-400",
!isOk && !isErr && "text-muted-foreground"
)}
/>
</div>
<p className="mt-2 text-xs font-semibold">{step.label}</p>
<p className="text-[10px] text-muted-foreground">
{step.sublabel}
</p>
{check.ms !== undefined && (
<p className="text-[10px] text-muted-foreground">
{check.ms}ms
</p>
)}
</div>
{/* Connector line */}
{i < CHECK_STEPS.length - 1 && (
<div
className={cn(
"mx-1 h-0.5 w-8 sm:w-12",
isOk ? "bg-emerald-400" : "bg-border"
)}
/>
)}
</div>
);
})}
</div>
{/* Error details */}
{data.overall === "error" && (
<div className="mt-4 space-y-2">
{CHECK_STEPS.map((step) => {
const check = checks[step.key];
if (check.status !== "error") return null;
return (
<div
key={step.key}
className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm dark:border-red-900 dark:bg-red-950/20"
>
<XCircle className="mt-0.5 size-4 shrink-0 text-red-500" />
<div>
<p className="font-medium text-red-800 dark:text-red-300">
{step.label} check failed
</p>
<p className="text-red-700 dark:text-red-400">
{check.error}
</p>
{check.detail && (
<p className="mt-1 font-mono text-xs text-red-600 dark:text-red-500">
{check.detail}
</p>
)}
</div>
</div>
);
})}
</div>
)}
{data.overall === "ok" && (
<div className="mt-4 flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
<CheckCircle2 className="size-4 text-emerald-500" />
<p className="font-medium text-emerald-800 dark:text-emerald-300">
All checks passed Keycloak connection is healthy
</p>
</div>
)}
</CardContent>
</Card>
);
}
const ALERT_TYPES = [
{ key: "notify_license_expiring", label: "License expiring soon", desc: "When a license enters the expiry warning window" },
{ key: "notify_cert_expiring", label: "Certificate expiring soon", desc: "When a certificate enters the expiry warning window" },
{ key: "notify_license_expired", label: "License expired", desc: "When a license has passed its expiration date" },
{ key: "notify_cert_expired", label: "Certificate expired", desc: "When a certificate has passed its expiration date" },
{ key: "notify_new_feedback", label: "New feedback submitted", desc: "When a user submits feedback from a project page" },
{ key: "notify_user_changes", label: "User changes", desc: "When users are added, removed, or deactivated" },
] as const;
function NotificationsSection({
form,
updateField,
}: {
form: Record<string, string>;
updateField: (key: string, value: string) => void;
}) {
const { data: smtp, isLoading: smtpLoading } = useSmtpStatus();
const sendTest = useSendTestEmail();
const [testEmail, setTestEmail] = useState("");
function handleSendTest() {
const to = testEmail.trim();
if (!to) {
toast.error("Enter a recipient email address");
return;
}
sendTest.mutate(to, {
onSuccess: (data) => toast.success(data.message),
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || "Failed to send test email";
toast.error(msg);
},
});
}
const enabled = form.email_notifications_enabled === "true";
return (
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">Email Notifications</CardTitle>
<p className="text-sm text-muted-foreground">
Configure which events trigger email alerts and who receives them.
SMTP connection is configured via environment variables.
</p>
</CardHeader>
<CardContent className="space-y-5">
{/* SMTP Status */}
{smtpLoading ? (
<Skeleton className="h-12 w-full" />
) : smtp?.configured ? (
<div className="flex items-start gap-2 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-500" />
<div className="flex-1">
<p className="font-medium text-emerald-800 dark:text-emerald-300">SMTP configured</p>
<p className="text-emerald-700 dark:text-emerald-400">
{smtp.host}:{smtp.port} {smtp.use_tls ? "(TLS)" : ""} &middot; From: {smtp.from_address}
{smtp.has_credentials ? "" : " · No credentials"}
</p>
</div>
<div className="flex items-center gap-2">
<Input
type="email"
placeholder="test@example.com"
className="w-48 h-8 text-xs"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
/>
<Button
variant="outline"
size="sm"
onClick={handleSendTest}
disabled={sendTest.isPending}
>
{sendTest.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Send className="size-3.5" />
)}
<span className="ml-1.5">Test</span>
</Button>
</div>
</div>
) : (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-900 dark:bg-amber-950/20">
<Mail className="mt-0.5 size-4 shrink-0 text-amber-500" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-300">SMTP not configured</p>
<p className="text-amber-700 dark:text-amber-400">
Set <code className="rounded bg-amber-100 px-1 py-0.5 text-xs font-mono dark:bg-amber-900/50">SMTP_HOST</code>,{" "}
<code className="rounded bg-amber-100 px-1 py-0.5 text-xs font-mono dark:bg-amber-900/50">SMTP_USERNAME</code>, and{" "}
<code className="rounded bg-amber-100 px-1 py-0.5 text-xs font-mono dark:bg-amber-900/50">SMTP_PASSWORD</code>{" "}
environment variables to enable email.
</p>
</div>
</div>
)}
{/* Master toggle */}
<div className="flex items-center gap-3">
<Label htmlFor="email-enabled" className="text-sm">Notifications</Label>
<Select value={form.email_notifications_enabled ?? "false"} onValueChange={(v) => v && updateField("email_notifications_enabled", v)}>
<SelectTrigger className="w-36 h-8" id="email-enabled">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">Disabled</SelectItem>
<SelectItem value="true">Enabled</SelectItem>
</SelectContent>
</Select>
{enabled && !smtp?.configured && (
<span className="text-xs text-amber-600 dark:text-amber-400">SMTP required</span>
)}
</div>
{/* Alert types */}
<div className={cn(!enabled && "opacity-50 pointer-events-none")}>
<p className="text-sm font-medium mb-3">Alert Types</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{ALERT_TYPES.map(({ key, label, desc }) => (
<label
key={key}
className="flex items-start gap-3 rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
>
<input
type="checkbox"
className="mt-0.5 size-4 rounded border-gray-300 text-primary accent-primary"
checked={form[key] === "true"}
onChange={(e) => updateField(key, e.target.checked ? "true" : "false")}
/>
<div>
<p className="text-sm font-medium leading-none">{label}</p>
<p className="text-xs text-muted-foreground mt-1">{desc}</p>
</div>
</label>
))}
</div>
</div>
{/* Recipients */}
<div className={cn(!enabled && "opacity-50 pointer-events-none")}>
<div className="space-y-1.5">
<Label htmlFor="email-address">Recipients</Label>
<Input
id="email-address"
type="text"
className="max-w-md"
placeholder="admin@example.com, team@example.com"
value={form.email_notifications_address ?? ""}
onChange={(e) => updateField("email_notifications_address", e.target.value)}
/>
<p className="text-xs text-muted-foreground">Comma-separated list of email addresses that will receive alerts</p>
</div>
</div>
</CardContent>
</Card>
);
}
function AllSettingsSection() {
const { data: settings, isLoading } = useAllSettings();
const updateSettings = useUpdateSettings();
const [form, setForm] = useState<Record<string, string>>({});
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (settings) {
setForm({
default_project_rate: settings.default_project_rate,
session_timeout_hours: settings.session_timeout_hours,
audit_log_retention_days: settings.audit_log_retention_days,
email_notifications_enabled: settings.email_notifications_enabled,
email_notifications_address: settings.email_notifications_address,
notify_license_expiring: settings.notify_license_expiring,
notify_cert_expiring: settings.notify_cert_expiring,
notify_license_expired: settings.notify_license_expired,
notify_cert_expired: settings.notify_cert_expired,
notify_new_feedback: settings.notify_new_feedback,
notify_user_changes: settings.notify_user_changes,
default_billing_type: settings.default_billing_type,
pkcs12_passphrase_required: settings.pkcs12_passphrase_required,
max_upload_size_mb: settings.max_upload_size_mb,
auto_archive_days: settings.auto_archive_days,
});
setDirty(false);
}
}, [settings]);
function updateField(key: string, value: string) {
setForm((prev) => ({ ...prev, [key]: value }));
setDirty(true);
}
function handleSave() {
// Convert types for the API
const payload: Record<string, unknown> = {
default_project_rate: parseFloat(form.default_project_rate),
session_timeout_hours: parseFloat(form.session_timeout_hours),
audit_log_retention_days: parseInt(form.audit_log_retention_days, 10),
email_notifications_enabled: form.email_notifications_enabled === "true",
email_notifications_address: form.email_notifications_address,
notify_license_expiring: form.notify_license_expiring === "true",
notify_cert_expiring: form.notify_cert_expiring === "true",
notify_license_expired: form.notify_license_expired === "true",
notify_cert_expired: form.notify_cert_expired === "true",
notify_new_feedback: form.notify_new_feedback === "true",
notify_user_changes: form.notify_user_changes === "true",
default_billing_type: form.default_billing_type,
pkcs12_passphrase_required: form.pkcs12_passphrase_required === "true",
max_upload_size_mb: parseInt(form.max_upload_size_mb, 10),
auto_archive_days: parseInt(form.auto_archive_days, 10),
};
updateSettings.mutate(payload, {
onSuccess: () => { toast.success("Settings saved"); setDirty(false); },
onError: () => toast.error("Failed to save settings"),
});
}
function handleBackup() {
window.open("/api/settings/backup", "_blank");
}
if (isLoading) {
return <Card><CardContent className="py-6"><Skeleton className="h-5 w-full" /></CardContent></Card>;
}
return (
<div className="space-y-4">
{/* General */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">General</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="default-rate">Default Project Rate ($)</Label>
<Input
id="default-rate"
type="number"
min={0}
step={0.01}
className="w-32"
value={form.default_project_rate ?? ""}
onChange={(e) => updateField("default_project_rate", e.target.value)}
/>
<p className="text-xs text-muted-foreground">Default hourly rate for new projects</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="default-billing">Default Billing Type</Label>
<Select value={form.default_billing_type ?? "core"} onValueChange={(v) => v && updateField("default_billing_type", v)}>
<SelectTrigger className="w-40" id="default-billing">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="core">Core</SelectItem>
<SelectItem value="collaborator">Collaborator</SelectItem>
<SelectItem value="standing">Standing</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Default billing type when adding users to projects</p>
</div>
</div>
</CardContent>
</Card>
{/* Security */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">Security</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="session-timeout">Session Timeout (hours)</Label>
<Input
id="session-timeout"
type="number"
min={1}
max={168}
className="w-28"
value={form.session_timeout_hours ?? ""}
onChange={(e) => updateField("session_timeout_hours", e.target.value)}
/>
<p className="text-xs text-muted-foreground">Requires server restart to take effect</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="pkcs12-passphrase">PKCS12 Export Passphrase</Label>
<Select value={form.pkcs12_passphrase_required ?? "false"} onValueChange={(v) => v && updateField("pkcs12_passphrase_required", v)}>
<SelectTrigger className="w-40" id="pkcs12-passphrase">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">No passphrase</SelectItem>
<SelectItem value="true">Require passphrase</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Whether PKCS12 exports include a passphrase</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="max-upload">Max Upload Size (MB)</Label>
<Input
id="max-upload"
type="number"
min={1}
max={500}
className="w-28"
value={form.max_upload_size_mb ?? ""}
onChange={(e) => updateField("max_upload_size_mb", e.target.value)}
/>
<p className="text-xs text-muted-foreground">Maximum file upload size. Requires server restart to take effect.</p>
</div>
</CardContent>
</Card>
{/* Maintenance */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">Maintenance</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="audit-retention">Audit Log Retention (days)</Label>
<Input
id="audit-retention"
type="number"
min={0}
max={3650}
className="w-28"
value={form.audit_log_retention_days ?? ""}
onChange={(e) => updateField("audit_log_retention_days", e.target.value)}
/>
<p className="text-xs text-muted-foreground">Auto-purge audit logs older than this. 0 = keep forever.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="auto-archive">Auto-Archive Expired Items (days)</Label>
<Input
id="auto-archive"
type="number"
min={0}
max={3650}
className="w-28"
value={form.auto_archive_days ?? ""}
onChange={(e) => updateField("auto_archive_days", e.target.value)}
/>
<p className="text-xs text-muted-foreground">Archive expired licenses/certs after this many days. 0 = disabled.</p>
</div>
</div>
</CardContent>
</Card>
{/* Notifications */}
<NotificationsSection form={form} updateField={updateField} />
{/* Save + Backup */}
<div className="flex items-center justify-between">
<Button onClick={handleSave} disabled={!dirty || updateSettings.isPending}>
{updateSettings.isPending ? "Saving..." : "Save Settings"}
</Button>
<Button variant="outline" onClick={handleBackup}>
<Database className="size-4 mr-2" />
Download Database Backup
</Button>
</div>
</div>
);
}
export default function AdminPage() {
const { data: feedbackList, isLoading } = useFeedbackList();
const [selectedId, setSelectedId] = useState<number | null>(null);
@@ -174,73 +729,110 @@ export default function AdminPage() {
<h1 className="text-2xl font-semibold tracking-tight">Admin</h1>
</div>
{/* Expiry Thresholds */}
<section>
<h2 className="mb-3 text-lg font-semibold">Settings</h2>
<ExpiryThresholdsSection />
</section>
<Tabs defaultValue="settings">
<TabsList>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="feedback">Feedback</TabsTrigger>
<TabsTrigger value="keycloak">Keycloak</TabsTrigger>
</TabsList>
{/* Feedback Table */}
<section>
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Project</TableHead>
<TableHead>User</TableHead>
<TableHead>Message</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TabsContent value="settings">
<div className="space-y-4">
<ExpiryThresholdsSection />
<AllSettingsSection />
</div>
</TabsContent>
<TabsContent value="feedback">
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableCell colSpan={5} className="py-8">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableHead>Date</TableHead>
<TableHead>Project</TableHead>
<TableHead>User</TableHead>
<TableHead>Message</TableHead>
<TableHead>Status</TableHead>
</TableRow>
) : !feedbackList || feedbackList.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground py-8"
>
No feedback submitted yet.
</TableCell>
</TableRow>
) : (
feedbackList.map((fb) => (
<TableRow
key={fb.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => setSelectedId(fb.id)}
>
<TableCell className="whitespace-nowrap text-muted-foreground">
{fb.created_at
? format(parseISO(fb.created_at), "MMM d, yyyy h:mm a")
: "—"}
</TableCell>
<TableCell className="font-mono text-xs">
{fb.project_key}
</TableCell>
<TableCell>{fb.username ?? "—"}</TableCell>
<TableCell className="max-w-xs truncate">
{fb.message}
</TableCell>
<TableCell>
<FeedbackStatusBadge status={fb.status} />
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="py-8">
<Skeleton className="h-5 w-full" />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</section>
) : !feedbackList || feedbackList.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground py-8"
>
No feedback submitted yet.
</TableCell>
</TableRow>
) : (
feedbackList.map((fb) => (
<TableRow
key={fb.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => setSelectedId(fb.id)}
>
<TableCell className="whitespace-nowrap text-muted-foreground">
{fb.created_at
? format(parseISO(fb.created_at), "MMM d, yyyy h:mm a")
: "—"}
</TableCell>
<TableCell className="font-mono text-xs">
{fb.project_key}
</TableCell>
<TableCell>{fb.username ?? "—"}</TableCell>
<TableCell className="max-w-xs truncate">
{fb.message}
</TableCell>
<TableCell>
<FeedbackStatusBadge status={fb.status} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="keycloak">
<div className="space-y-4">
<KeycloakDiagnosticsSection />
<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>
</div>
</TabsContent>
</Tabs>
{/* Feedback Detail Dialog */}
<Dialog

View File

@@ -31,7 +31,7 @@ import {
} from "@/components/ui/table";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { ActivityFeed } from "@/components/shared/activity-feed";
import { useUser, useUpdateUser, useUserHistory, useAddUserToProject, useUpdateMembership, type User } from "@/hooks/use-users";
import { useUser, useUpdateUser, useUserHistory, useAddUserToProject, useUpdateMembership, type User, type ProjectMembership } from "@/hooks/use-users";
import { useProjects } from "@/hooks/use-projects";
const BILLING_LABELS: Record<string, { label: string; className: string }> = {
@@ -64,25 +64,6 @@ const PRIVILEGE_LABELS: Record<string, { label: string; className: string }> = {
},
};
function BillingBadge({ type }: { type: string }) {
const style = BILLING_LABELS[type] ?? BILLING_LABELS.standing;
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${style.className}`}>
{style.label}
</span>
);
}
function PrivilegeBadge({ level }: { level: string }) {
const style = PRIVILEGE_LABELS[level];
if (!style) return <span className="text-muted-foreground"></span>;
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${style.className}`}>
{style.label}
</span>
);
}
function formatUserEntry(entry: {
action: string;
user: string;
@@ -172,7 +153,7 @@ export default function UserDetailPage() {
(p) => !user.projects.some((m) => m.project_key === p.key)
) ?? [];
function handleBillingChange(m: (typeof user.projects)[number], newType: string) {
function handleBillingChange(m: ProjectMembership, newType: string) {
if (newType === m.billing_type || !userId) return;
updateMembership.mutate(
{ project_key: m.project_key, user_id: userId, billing_type: newType },
@@ -217,7 +198,7 @@ export default function UserDetailPage() {
);
}
function handlePrivilegeChange(m: (typeof user.projects)[number], newPrivilege: string) {
function handlePrivilegeChange(m: ProjectMembership, newPrivilege: string) {
if (newPrivilege === m.privileges || !userId) return;
updateMembership.mutate(
{ project_key: m.project_key, user_id: userId, privileges: newPrivilege },

550
scripts/seed-keycloak.py Executable file
View File

@@ -0,0 +1,550 @@
#!/usr/bin/env python3
"""
Seed a Keycloak realm with OSA Suite sample data.
Creates the realm (if needed), a service-account client, users, groups
(one per project), and group memberships matching the local seed data.
Usage:
# Against local Keycloak (defaults)
python scripts/seed-keycloak.py
# Against a remote instance
python scripts/seed-keycloak.py \
--url https://keycloak.example.com \
--admin-user admin \
--admin-password changeme \
--realm osa
# Using environment variables
KEYCLOAK_URL=http://localhost:8180 python scripts/seed-keycloak.py
Prerequisites:
pip install requests
"""
import argparse
import os
import sys
import requests
# ── Seed data (imported from the main seed file) ─────────────────
# Inline the data we need so this script is fully standalone.
PROJECTS = [
{"key": "ACME", "name": "Acme Retail Platform"},
{"key": "ATLAS", "name": "Atlas HR Suite"},
{"key": "BEACN", "name": "Beacon CRM"},
{"key": "CITDL", "name": "Citadel Security Portal"},
{"key": "DELTA", "name": "Delta Logistics Tracker"},
{"key": "ECHO", "name": "Echo Learning Platform"},
{"key": "FORGE", "name": "Forge Developer Tools"},
{"key": "GLACR", "name": "Glacier Backup Service"},
{"key": "HARBR", "name": "Harbor API Gateway"},
{"key": "IRIS", "name": "Iris Analytics Dashboard"},
{"key": "JADE", "name": "Jade Communications Hub"},
{"key": "KYSTO", "name": "Keystone Identity Management"},
{"key": "LUNAR", "name": "Lunar Financial Reporting"},
{"key": "METOR", "name": "Meteor Cloud Operations"},
{"key": "NOVA", "name": "Nova Mobile App"},
]
USERS = [
{"name": "Priya Patel", "email": "priya.patel@acme.com"},
{"name": "Michael Chen", "email": "michael.chen@acme.com"},
{"name": "David Nguyen", "email": "david.nguyen@acme.com"},
{"name": "Laura Kim", "email": "laura.kim@atlas.io"},
{"name": "James Rodriguez", "email": "james.r@atlas.io"},
{"name": "Sarah Thompson", "email": "sarah.t@atlas.io"},
{"name": "Tom Bradley", "email": "tom.bradley@beacon.co"},
{"name": "Nina Vasquez", "email": "nina.v@beacon.co"},
{"name": "Chris Lee", "email": "chris.lee@beacon.co"},
{"name": "Angela Morris", "email": "amorris@citadel.net"},
{"name": "Raj Kapoor", "email": "raj.kapoor@citadel.net"},
{"name": "Emily Sato", "email": "emily.sato@citadel.net"},
{"name": "Frank Gomez", "email": "fgomez@delta.com"},
{"name": "Karen Wu", "email": "karen.wu@delta.com"},
{"name": "Marcus Johnson", "email": "marcus.j@delta.com"},
{"name": "Diana Ross", "email": "diana.ross@echo.edu"},
{"name": "Steve Park", "email": "steve.park@echo.edu"},
{"name": "Omar Hassan", "email": "omar.h@forge.dev"},
{"name": "Rachel Green", "email": "rachel.g@forge.dev"},
{"name": "Kevin Brown", "email": "kevin.b@forge.dev"},
{"name": "Helen Zhang", "email": "helen.z@glacier.io"},
{"name": "Brian Miller", "email": "brian.m@glacier.io"},
{"name": "Paul Davis", "email": "paul.d@harbor.com"},
{"name": "Michelle Tanaka", "email": "michelle.t@harbor.com"},
{"name": "Sandra Kowalski", "email": "sandra.k@iris.co"},
{"name": "Derek Olson", "email": "derek.o@iris.co"},
{"name": "Victor Lam", "email": "victor.l@jade.com"},
{"name": "Julia Fischer", "email": "julia.f@jade.com"},
{"name": "Natalie Brooks", "email": "natalie.b@keystone.sec"},
{"name": "Ryan Cooper", "email": "ryan.c@keystone.sec"},
{"name": "Patricia Novak", "email": "patricia.n@meteor.ops"},
{"name": "Andrew Kim", "email": "andrew.k@meteor.ops"},
{"name": "Henry Tran", "email": "henry.t@nova.app"},
{"name": "Rebecca Stone", "email": "rebecca.s@nova.app"},
{"name": "Jason Lim", "email": "jason.l@nova.app"},
{"name": "Aiden Walsh", "email": "aiden.w@acme.com"},
{"name": "Sofia Reyes", "email": "sofia.r@acme.com"},
{"name": "Liam O'Connor", "email": "liam.oc@acme.com"},
{"name": "Mia Johannsen", "email": "mia.j@atlas.io"},
{"name": "Ethan Blake", "email": "ethan.b@atlas.io"},
{"name": "Chloe Dubois", "email": "chloe.d@beacon.co"},
{"name": "Noah Pham", "email": "noah.p@beacon.co"},
{"name": "Ava Petrov", "email": "ava.p@citadel.net"},
{"name": "Lucas Ferreira", "email": "lucas.f@citadel.net"},
{"name": "Zara Okafor", "email": "zara.o@delta.com"},
{"name": "Isaac Yamamoto", "email": "isaac.y@echo.edu"},
{"name": "Ella Johansson", "email": "ella.j@echo.edu"},
{"name": "Oliver Katz", "email": "oliver.k@forge.dev"},
{"name": "Harper Singh", "email": "harper.s@glacier.io"},
{"name": "William Torres", "email": "william.t@harbor.com"},
{"name": "Grace Nakamura", "email": "grace.n@iris.co"},
{"name": "Benjamin Cho", "email": "benjamin.c@jade.com"},
{"name": "Lily Andersen", "email": "lily.a@keystone.sec"},
{"name": "Jack Morales", "email": "jack.m@meteor.ops"},
{"name": "Aria Bianchi", "email": "aria.b@nova.app"},
]
# (user_email, project_idx, billing_type, privileges)
PROJECT_USER_ASSIGNMENTS = [
("priya.patel@acme.com", 0, "standing", ""),
("michael.chen@acme.com", 0, "standing", ""),
("david.nguyen@acme.com", 0, "standing", ""),
("aiden.w@acme.com", 0, "core", "admin"),
("sofia.r@acme.com", 0, "core", "write"),
("liam.oc@acme.com", 0, "core", "write"),
("oliver.k@forge.dev", 0, "collaborator", "read"),
("laura.kim@atlas.io", 1, "standing", ""),
("james.r@atlas.io", 1, "standing", ""),
("sarah.t@atlas.io", 1, "standing", ""),
("mia.j@atlas.io", 1, "core", "admin"),
("ethan.b@atlas.io", 1, "core", "write"),
("sofia.r@acme.com", 1, "collaborator", "read"),
("tom.bradley@beacon.co", 2, "standing", ""),
("nina.v@beacon.co", 2, "standing", ""),
("chris.lee@beacon.co", 2, "standing", ""),
("chloe.d@beacon.co", 2, "core", "admin"),
("noah.p@beacon.co", 2, "core", "write"),
("grace.n@iris.co", 2, "collaborator", "read"),
("amorris@citadel.net", 3, "standing", ""),
("raj.kapoor@citadel.net", 3, "standing", ""),
("emily.sato@citadel.net", 3, "standing", ""),
("ava.p@citadel.net", 3, "core", "admin"),
("lucas.f@citadel.net", 3, "core", "write"),
("fgomez@delta.com", 4, "standing", ""),
("karen.wu@delta.com", 4, "standing", ""),
("marcus.j@delta.com", 4, "standing", ""),
("zara.o@delta.com", 4, "core", "admin"),
("diana.ross@echo.edu", 5, "standing", ""),
("steve.park@echo.edu", 5, "standing", ""),
("isaac.y@echo.edu", 5, "core", "admin"),
("ella.j@echo.edu", 5, "core", "write"),
("omar.h@forge.dev", 6, "standing", ""),
("rachel.g@forge.dev", 6, "standing", ""),
("kevin.b@forge.dev", 6, "standing", ""),
("oliver.k@forge.dev", 6, "core", "admin"),
("liam.oc@acme.com", 6, "collaborator", "read"),
("helen.z@glacier.io", 7, "standing", ""),
("brian.m@glacier.io", 7, "standing", ""),
("harper.s@glacier.io", 7, "core", "admin"),
("paul.d@harbor.com", 8, "standing", ""),
("michelle.t@harbor.com", 8, "standing", ""),
("william.t@harbor.com", 8, "core", "admin"),
("lucas.f@citadel.net", 8, "collaborator", "write"),
("sandra.k@iris.co", 9, "standing", ""),
("derek.o@iris.co", 9, "standing", ""),
("grace.n@iris.co", 9, "core", "admin"),
("victor.l@jade.com", 10, "standing", ""),
("julia.f@jade.com", 10, "standing", ""),
("benjamin.c@jade.com", 10, "core", "admin"),
("natalie.b@keystone.sec", 11, "standing", ""),
("ryan.c@keystone.sec", 11, "standing", ""),
("lily.a@keystone.sec", 11, "core", "admin"),
("patricia.n@meteor.ops", 13, "standing", ""),
("andrew.k@meteor.ops", 13, "standing", ""),
("jack.m@meteor.ops", 13, "core", "admin"),
("henry.t@nova.app", 14, "standing", ""),
("rebecca.s@nova.app", 14, "standing", ""),
("jason.l@nova.app", 14, "standing", ""),
("aria.b@nova.app", 14, "core", "write"),
("chloe.d@beacon.co", 14, "collaborator", "read"),
]
SERVICE_CLIENT_ID = "osa-admin-client"
# ── Keycloak API helpers ─────────────────────────────────────────
class KeycloakSeeder:
def __init__(self, base_url, admin_user, admin_password, realm):
self.base_url = base_url.rstrip("/")
self.admin_user = admin_user
self.admin_password = admin_password
self.realm = realm
self._token = None
def _get_master_token(self):
"""Get an admin token from the master realm."""
if self._token:
return self._token
resp = requests.post(
f"{self.base_url}/realms/master/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": "admin-cli",
"username": self.admin_user,
"password": self.admin_password,
},
timeout=10,
)
if resp.status_code != 200:
print(f"ERROR: Failed to authenticate as {self.admin_user}: {resp.status_code}")
print(resp.text)
sys.exit(1)
self._token = resp.json()["access_token"]
return self._token
def _headers(self):
return {
"Authorization": f"Bearer {self._get_master_token()}",
"Content-Type": "application/json",
}
def _admin_url(self, path=""):
return f"{self.base_url}/admin/realms/{self.realm}{path}"
# ── Realm ────────────────────────────────────────────────────
def ensure_realm(self):
"""Create the realm if it doesn't exist."""
resp = requests.get(
f"{self.base_url}/admin/realms/{self.realm}",
headers=self._headers(),
timeout=10,
)
if resp.status_code == 200:
print(f" Realm '{self.realm}' already exists")
return
resp = requests.post(
f"{self.base_url}/admin/realms",
headers=self._headers(),
json={
"realm": self.realm,
"enabled": True,
"displayName": "OSA Suite",
},
timeout=10,
)
if resp.status_code == 201:
print(f" Realm '{self.realm}' created")
elif resp.status_code == 409:
print(f" Realm '{self.realm}' already exists")
else:
print(f" ERROR creating realm: {resp.status_code} {resp.text}")
sys.exit(1)
# ── Service-account client ───────────────────────────────────
def ensure_service_client(self):
"""Create the osa-admin-client with service account and realm-admin role."""
resp = requests.get(
self._admin_url(f"/clients?clientId={SERVICE_CLIENT_ID}"),
headers=self._headers(),
timeout=10,
)
clients = resp.json()
if clients:
client_id = clients[0]["id"]
print(f" Client '{SERVICE_CLIENT_ID}' already exists (id={client_id[:8]}...)")
else:
resp = requests.post(
self._admin_url("/clients"),
headers=self._headers(),
json={
"clientId": SERVICE_CLIENT_ID,
"name": "OSA Suite Admin Client",
"enabled": True,
"clientAuthenticatorType": "client-secret",
"serviceAccountsEnabled": True,
"publicClient": False,
"protocol": "openid-connect",
"standardFlowEnabled": False,
"directAccessGrantsEnabled": False,
},
timeout=10,
)
if resp.status_code not in (201, 409):
print(f" ERROR creating client: {resp.status_code} {resp.text}")
return
# Fetch the created client
resp = requests.get(
self._admin_url(f"/clients?clientId={SERVICE_CLIENT_ID}"),
headers=self._headers(),
timeout=10,
)
clients = resp.json()
client_id = clients[0]["id"]
print(f" Client '{SERVICE_CLIENT_ID}' created (id={client_id[:8]}...)")
# Get the client secret
resp = requests.get(
self._admin_url(f"/clients/{client_id}/client-secret"),
headers=self._headers(),
timeout=10,
)
secret = resp.json().get("value", "")
# Assign realm-admin role to the service account
self._assign_realm_admin(client_id)
return secret
def _assign_realm_admin(self, client_uuid):
"""Give the service account the realm-admin role from realm-management."""
# Get service account user
resp = requests.get(
self._admin_url(f"/clients/{client_uuid}/service-account-user"),
headers=self._headers(),
timeout=10,
)
if resp.status_code != 200:
print(" WARN: Could not find service account user")
return
sa_user_id = resp.json()["id"]
# Find the realm-management client
resp = requests.get(
self._admin_url("/clients?clientId=realm-management"),
headers=self._headers(),
timeout=10,
)
rm_clients = resp.json()
if not rm_clients:
print(" WARN: realm-management client not found")
return
rm_id = rm_clients[0]["id"]
# Get the realm-admin role
resp = requests.get(
self._admin_url(f"/clients/{rm_id}/roles/realm-admin"),
headers=self._headers(),
timeout=10,
)
if resp.status_code != 200:
print(" WARN: realm-admin role not found")
return
role = resp.json()
# Assign it
resp = requests.post(
self._admin_url(f"/users/{sa_user_id}/role-mappings/clients/{rm_id}"),
headers=self._headers(),
json=[role],
timeout=10,
)
if resp.status_code == 204:
print(" Service account granted realm-admin role")
elif resp.status_code == 409:
print(" Service account already has realm-admin role")
else:
print(f" WARN: Could not assign realm-admin: {resp.status_code}")
# ── Users ────────────────────────────────────────────────────
def create_users(self):
"""Create all seed users. Returns {email: keycloak_id} map."""
user_map = {}
created = 0
skipped = 0
for u in USERS:
email = u["email"]
name = u["name"]
first = name.split()[0]
last = " ".join(name.split()[1:]) if " " in name else ""
resp = requests.post(
self._admin_url("/users"),
headers=self._headers(),
json={
"username": email,
"email": email,
"firstName": first,
"lastName": last,
"enabled": True,
"emailVerified": True,
},
timeout=10,
)
if resp.status_code == 201:
location = resp.headers.get("Location", "")
kc_id = location.rsplit("/", 1)[-1] if location else None
if kc_id:
user_map[email] = kc_id
created += 1
elif resp.status_code == 409:
# Already exists — look up by email
kc_id = self._find_user(email)
if kc_id:
user_map[email] = kc_id
skipped += 1
else:
print(f" WARN: Failed to create user {email}: {resp.status_code}")
print(f" Users: {created} created, {skipped} already existed")
return user_map
def _find_user(self, email):
resp = requests.get(
self._admin_url("/users"),
headers=self._headers(),
params={"email": email, "exact": "true"},
timeout=10,
)
users = resp.json()
return users[0]["id"] if users else None
# ── Groups (projects) ────────────────────────────────────────
def create_groups(self):
"""Create groups for each project. Returns {project_key: group_id} map."""
group_map = {}
created = 0
skipped = 0
for p in PROJECTS:
group_name = f"{p['key']} - {p['name']}"
resp = requests.post(
self._admin_url("/groups"),
headers=self._headers(),
json={"name": group_name},
timeout=10,
)
if resp.status_code == 201:
location = resp.headers.get("Location", "")
group_id = location.rsplit("/", 1)[-1] if location else None
if group_id:
group_map[p["key"]] = group_id
created += 1
elif resp.status_code == 409:
group_id = self._find_group(group_name)
if group_id:
group_map[p["key"]] = group_id
skipped += 1
else:
print(f" WARN: Failed to create group {group_name}: {resp.status_code}")
print(f" Groups: {created} created, {skipped} already existed")
return group_map
def _find_group(self, name):
resp = requests.get(
self._admin_url("/groups"),
headers=self._headers(),
params={"search": name, "exact": "true"},
timeout=10,
)
for g in resp.json():
if g["name"] == name:
return g["id"]
return None
# ── Group memberships ────────────────────────────────────────
def assign_memberships(self, user_map, group_map):
"""Add users to their project groups."""
assigned = 0
skipped = 0
for email, proj_idx, _billing, _priv in PROJECT_USER_ASSIGNMENTS:
if proj_idx >= len(PROJECTS):
continue
project_key = PROJECTS[proj_idx]["key"]
user_id = user_map.get(email)
group_id = group_map.get(project_key)
if not user_id or not group_id:
skipped += 1
continue
resp = requests.put(
self._admin_url(f"/users/{user_id}/groups/{group_id}"),
headers=self._headers(),
timeout=10,
)
if resp.status_code == 204:
assigned += 1
else:
skipped += 1
print(f" Memberships: {assigned} assigned, {skipped} skipped")
# ── Main ─────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Seed a Keycloak realm with OSA Suite sample data."
)
parser.add_argument(
"--url",
default=os.environ.get("KEYCLOAK_URL", "http://localhost:8180"),
help="Keycloak base URL (default: $KEYCLOAK_URL or http://localhost:8180)",
)
parser.add_argument(
"--admin-user",
default=os.environ.get("KEYCLOAK_ADMIN", "admin"),
help="Keycloak admin username (default: $KEYCLOAK_ADMIN or admin)",
)
parser.add_argument(
"--admin-password",
default=os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "admin"),
help="Keycloak admin password (default: $KEYCLOAK_ADMIN_PASSWORD or admin)",
)
parser.add_argument(
"--realm",
default=os.environ.get("KEYCLOAK_REALM", "osa"),
help="Realm name to create/seed (default: $KEYCLOAK_REALM or osa)",
)
args = parser.parse_args()
print(f"Seeding Keycloak at {args.url}, realm '{args.realm}'")
print()
seeder = KeycloakSeeder(args.url, args.admin_user, args.admin_password, args.realm)
print("[1/4] Realm")
seeder.ensure_realm()
print("[2/4] Service client")
secret = seeder.ensure_service_client()
print("[3/4] Users")
user_map = seeder.create_users()
print("[4/4] Groups & memberships")
group_map = seeder.create_groups()
seeder.assign_memberships(user_map, group_map)
print()
print("Done! To connect OSA Suite to this Keycloak instance:")
print()
print(f" KEYCLOAK_URL={args.url}")
print(f" KEYCLOAK_REALM={args.realm}")
print(f" KEYCLOAK_CLIENT_ID={SERVICE_CLIENT_ID}")
if secret:
print(f" KEYCLOAK_CLIENT_SECRET={secret}")
else:
print(f" KEYCLOAK_CLIENT_SECRET=<check Keycloak admin console>")
print()
if __name__ == "__main__":
main()