Admin API — Operator & Integrator Guide (backend reference)¶
Audience: Platform operators, system integrators, DevOps engineers
Scope: SuperAdmin-only platform management — Tenant Management and Integration Hub
Version: 1.0
No admin UI ships in v1
The backend API documented here (/api/platform/admin/*, impersonation,
integrations, webhooks) is real and verified. The web console it was designed
for is not shipped — no frontend service exists in the self-host deploy.
Passages describing UI behavior (routes under /admin, localStorage, guards)
describe the future console; read them as API semantics (e.g. send the
X-Impersonate-Tenant header yourself). Drive everything below with curl or
the lm CLI.
1. Overview¶
The Admin Console is a SuperAdmin-only section of the lumnik frontend that provides two distinct capability areas:
- Tenant Management — view all tenants registered on the platform, inspect their details, and impersonate a tenant to act on their behalf.
- Integration Hub — configure outbound webhook integrations per tenant, inspect delivery history, and replay failed deliveries.
The console is accessible at /admin and is only rendered when the authenticated user holds the lm_superadmin role. Regular tenant admins and users do not see this section.
The Admin Console is intentionally separate from the normal tenant-scoped UI. When a SuperAdmin impersonates a tenant, they enter the Admin Shell for that tenant and can access the Integration Hub scoped to it. All actions remain audited and tenant-isolated at the backend.
2. SuperAdmin Role¶
2.1 Keycloak Role¶
Access to the Admin Console is gated on the lm_superadmin Keycloak role. The frontend reads this from the JWT claim roles (realm roles array). The relevant check in the frontend guard:
roles.includes('lm_superadmin')
The backend likewise enforces this role on all /api/platform/admin/* endpoints.
2.2 Dev Bypass Mode¶
In development, the application can run with a bypass that activates all three roles simultaneously:
| Role | Purpose |
|---|---|
lm_superadmin |
Admin Console access, tenant impersonation |
lm_admin |
Tenant-level admin operations |
lm_user |
Standard authenticated user |
This allows a single dev user to exercise the full surface of the application without configuring Keycloak roles. The bypass is controlled by an environment/build flag and must never be enabled in production.
2.3 Protected Endpoints¶
| Method | Path | Permission |
|---|---|---|
GET |
/api/platform/admin/tenants |
lm_superadmin |
POST |
/api/platform/admin/impersonate/{tid} |
lm_superadmin |
DELETE |
/api/platform/admin/impersonate |
lm_superadmin |
These are served by TenantListResource and ImpersonationResource respectively.
3. Impersonation¶
Impersonation allows a SuperAdmin to act on behalf of a specific tenant without logging in as a tenant user. It is scoped to the browser session and expires automatically.
3.1 Starting Impersonation¶
POST /api/platform/admin/impersonate/{tid}
Response — ImpersonationToken:
{
"actingAsTenant": "tenant-uuid",
"expiresAt": "2026-05-21T14:00:00Z",
"hint": "Acme Corp"
}
| Field | Description |
|---|---|
actingAsTenant |
The tenant ID being impersonated |
expiresAt |
ISO-8601 timestamp after which the token is invalid |
hint |
Display name of the tenant (shown in the UI badge) |
3.2 Frontend Behaviour¶
The ImpersonationService stores the token in localStorage. The AdminApiService automatically injects the X-Impersonate-Tenant header into every subsequent API call for the duration of the impersonation session:
X-Impersonate-Tenant: tenant-uuid
No further user action is required — the header is transparent to all other frontend services.
3.3 Visual Cue¶
While impersonating, a red badge is rendered in the Admin Shell header showing:
- The tenant hint (e.g.,
Acme Corp) - A "Quitter" button to end the session immediately
This makes it unambiguous that the operator is acting on behalf of another tenant.
3.4 Backend Enforcement¶
The TenantContextFilter intercepts incoming requests and reads the X-Impersonate-Tenant header. The header is only honoured when the caller holds the lm_superadmin role. For all other callers the header is silently ignored, preventing privilege escalation.
3.5 Ending Impersonation¶
Two methods:
- UI: Click the "Quitter" button in the red badge.
- API:
DELETE /api/platform/admin/impersonate
Both clear the stored token from localStorage and remove the X-Impersonate-Tenant header from subsequent calls.
4. Integration Hub¶
The Integration Hub allows a SuperAdmin (while impersonating a tenant) to configure outbound webhook integrations. Each integration subscribes to one or more event types and delivers signed HTTP payloads to an external URL.
4.1 Endpoints¶
| Method | Path | Description |
|---|---|---|
GET |
/api/tenants/{tid}/integrations |
List integrations for a tenant |
POST |
/api/tenants/{tid}/integrations |
Create a new integration |
POST |
/api/tenants/{tid}/integrations/{id}/test |
Fire a test event |
GET |
/api/tenants/{tid}/integrations/{id}/deliveries |
Delivery history |
POST |
/api/tenants/{tid}/integrations/{id}/deliveries/{did}/replay |
Replay a delivery |
4.2 Creating an Integration — IntegrationCreate Fields¶
{
"name": "My Zapier Hook",
"targetUrl": "https://hooks.zapier.com/hooks/catch/...",
"platform": "zapier",
"authMode": "HMAC",
"secret": "my-shared-secret",
"eventTypes": ["org.created", "org.updated"],
"enabled": true
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Human-readable label |
targetUrl |
string | yes | Destination URL for webhook delivery |
platform |
string | yes | Platform preset: generic, make, zapier, n8n |
authMode |
enum | yes | One of HMAC, BEARER, NONE |
secret |
string | conditional | Required for HMAC and BEARER modes |
eventTypes |
string[] | yes | List of event type identifiers to subscribe to |
enabled |
boolean | yes | Whether delivery is active |
4.3 Authentication Modes¶
HMAC¶
The delivery adds a signature header computed from the raw payload and the stored secret:
X-Lumnik-Signature: sha256=<hex-encoded HMAC-SHA256>
The receiving service must verify this signature using the same shared secret. See Section 6 for the exact algorithm.
BEARER¶
The delivery adds a standard Authorization header:
Authorization: Bearer <secret>
NONE¶
No authentication header is added. Use only for endpoints that enforce their own access control at the network layer.
4.4 Secret Storage¶
Secrets are never stored in plaintext. They are encrypted at rest using PostgreSQL's pgcrypto extension:
pgp_sym_encrypt(secret, app_key)
The API never returns the secret value. Instead, the integration record exposes a boolean:
{ "hasSecret": true }
To rotate a secret, update the integration with a new secret value.
4.5 Sending a Test Event¶
POST /api/tenants/{tid}/integrations/{id}/test
Content-Type: application/json
{ "eventType": "org.created" }
This fires a real delivery attempt to the configured targetUrl using the configured authMode. The result is recorded in the delivery history just like a production delivery. Use this to verify connectivity and signature verification before enabling the integration for live events.
4.6 Delivery History¶
GET /api/tenants/{tid}/integrations/{id}/deliveries
GET /api/tenants/{tid}/integrations/{id}/deliveries?status=failed
Each delivery record includes:
| Field | Description |
|---|---|
http_status |
HTTP response code from the target |
response_body |
First N bytes of the response body |
error |
Error message if the attempt failed at the transport level |
duration_ms |
Round-trip time in milliseconds |
attempted_at |
ISO-8601 timestamp of the attempt |
Filter by status=failed to surface actionable failures.
4.7 Replaying a Delivery¶
POST /api/tenants/{tid}/integrations/{id}/deliveries/{did}/replay
Re-sends the original payload of delivery did to the configured targetUrl. Useful for recovering from transient failures. The replay is recorded as a new delivery entry; the original entry is preserved.
4.8 Platform Presets¶
The UI offers platform-specific presets that pre-fill recommended settings:
| Preset | Notes |
|---|---|
generic |
No preset defaults; fully manual configuration |
make |
Make.com (formerly Integromat) webhook receiver |
zapier |
Zapier catch hooks |
n8n |
n8n Webhook node |
Presets affect the UI only — the backend treats all integrations identically.
5. Event Catalog¶
5.1 Listing Available Events¶
GET /api/platform/events
Returns the full list of event types registered on this platform instance, including their module, type identifier, human-readable description, and payload schema.
Example response entry:
{
"module": "core",
"type": "org.created",
"description": "Fired when a new organisation is created",
"payload": {
"orgId": "UUID of the created organisation",
"name": "Display name of the organisation",
"parentId": "UUID of parent organisation, or null if root"
}
}
5.2 Registration Mechanism¶
Events are defined in META-INF/events.yaml files, one per module. These files are scanned at startup by EventRegistry, which builds the in-memory catalog. Adding a new event requires:
- Adding an entry to the module's
events.yaml - Redeploying the service (no database migration needed)
5.3 Current Event Catalog¶
Module: core¶
| Type | Description |
|---|---|
org.created |
A new organisation was created |
org.updated |
An organisation's attributes were updated |
org.moved |
An organisation was moved to a different parent |
gdpr.user.erased |
A user's personal data was erased per GDPR request |
Module: repair¶
| Type | Description |
|---|---|
repair.completed |
A repair order was completed |
repair.cancelled |
A repair order was cancelled |
6. Webhook Delivery Mechanics¶
6.1 Outbox Pattern¶
Events are not delivered inline with the originating transaction. Instead, each event is written to the core.audit_outbox table atomically with the operation that triggered it. This guarantees at-least-once delivery even if downstream services are temporarily unavailable.
The AuditDispatcher + WebhookConsumer pair polls core.audit_outbox on a fixed schedule. For each undelivered event it:
- Matches the event type against all active integrations subscribed to it
- Fires one HTTP POST per matching integration
- Records the outcome in
core.webhook_delivery - Marks the outbox entry as processed
6.2 HMAC Signature Algorithm¶
For integrations using authMode: HMAC:
signature = HmacSHA256(secret, raw_payload_bytes)
header_value = "sha256=" + hex(signature)
The X-Lumnik-Signature header is set to header_value. Receivers must compute the same HMAC using the shared secret and compare it in constant time.
6.3 Retry Policy¶
There is no automatic retry. Failed deliveries remain in the history and must be replayed explicitly via the replay endpoint (Section 4.7) or will be resent on the next outbox scan cycle if the outbox entry was not marked processed.
For time-sensitive integrations, monitor the delivery history for failures and set up alerting on the status=failed query.
7. Security Notes¶
| Concern | Mitigation |
|---|---|
| Secret confidentiality | Secrets encrypted at rest with pgcrypto pgp_sym_encrypt; never returned by API |
| Impersonation scope | X-Impersonate-Tenant header only honoured for callers with lm_superadmin role |
| Tenant isolation | Row-Level Security (RLS) on core.webhook_delivery prevents cross-tenant data access |
| Replay safety | Replay creates a new delivery record; original is immutable |
| Dev bypass | Dev bypass mode must not be deployed to production; activates all roles unconditionally |
Production deployments should:
- Rotate the pgcrypto encryption key on a schedule and re-encrypt existing secrets
- Audit impersonation events in the platform audit log
- Restrict
/api/platform/admin/*at the network layer (e.g., internal VPC only) if SuperAdmin access from public internet is not required
8. Frontend Navigation¶
| Path | View |
|---|---|
/admin |
Tenant List — SuperAdmin landing page showing all registered tenants |
/admin/:tid |
Admin Shell — per-tenant tabbed view; entry point for all tenant-scoped admin actions |
/admin/:tid/integrations |
Integration Hub — list and manage integrations for tenant tid |
/admin/:tid/integrations/:id/deliveries |
Delivery History — delivery log for a specific integration |
Navigation to /admin while lacking the lm_superadmin role redirects to the normal authenticated home page.
When impersonating, the red badge is visible on all /admin/:tid/* routes and provides a consistent one-click exit point.
9. Integrator Platform API (/api/platform/*)¶
Everything under /api/platform/* is gated by PlatformApiAuthFilter: an unauthenticated
call gets 401 (with a WWW-Authenticate: Bearer challenge), an authenticated caller
without the lm_integrator role gets 403. In dev the gate is bypassed via
lumnik.security.dev-bypass=true. These are the endpoints the lm CLI/TUI drives.
9.1 Live Event Stream (SSE)¶
GET /api/platform/events/stream?topic=connector.run.*
Accept: text/event-stream
Served by EventStreamResource. Streams platform events as Server-Sent Events, each
element a JSON envelope. This is how the TUI :runs / :dlq / :inbox views live-update.
| Aspect | Behaviour |
|---|---|
topic parameter |
Repeatable (?topic=a&topic=b); * is a glob wildcard matched against the full event type (e.g. connector.run.*). No topic = all events. |
| Source | Polls core.event_outbox every second via the admin datasource (BYPASSRLS) — events across all tenants; batches up to 500 rows per tick. |
| Envelope | {"topic": "<event_type>", "payload": "<JSON string>", "capturedAt": "<ISO-8601>"} — note payload arrives as a JSON string, parse it client-side. |
| Permission | lm_integrator |
9.2 Runs¶
Served by RunAdminResource. Read-only run transparency.
| Method | Path | Permission | Contract |
|---|---|---|---|
GET |
/api/platform/runs?limit= |
lm_integrator |
List runs (default 50, capped at 200) |
GET |
/api/platform/runs/{id} |
lm_integrator |
One run; 404 if unknown |
Each run record (RunAdminDto) carries the conservation fields:
| Field | Description |
|---|---|
id, connectorId |
Run and connector UUIDs |
status |
Run status |
startedAt, endedAt |
ISO-8601 timestamps |
recordsIn |
Rows read from the source |
recordsOut |
Rows written to the hub |
deadLetterCount |
Rows captured in the DLQ for this run |
triggeredBy |
Who/what started the run |
chunkCount |
Number of transactional chunks |
errorMessage |
Failure detail, if any |
9.3 Dead-Letter Queue¶
Served by DlqAdminResource. Rows a run could not ingest, with the offending payload.
| Method | Path | Permission | Contract |
|---|---|---|---|
GET |
/api/platform/dlq?limit=&connector=&run= |
lm_integrator |
List entries (default 100, capped at 200) |
GET |
/api/platform/dlq/{id} |
lm_integrator |
One entry incl. payload + skipDetail; 404 if unknown or erased |
POST |
/api/platform/dlq/{id}/replayed?newRunId= |
lm_integrator |
Mark an entry as replayed (optionally linking the new run); 204 |
POST |
/api/platform/dlq/{id}/discard |
lm_integrator |
Discard an entry (resolution recorded, not deleted); 204 |
List filter semantics:
?run=— full history for that run (all reasons and resolutions). Wins over?connector=when both are passed.?connector=— pending queue only (unresolved entries for that connector).- No filter — all active entries; GDPR-erased (soft-deleted) rows are excluded from every branch.
Entries expose skipReason, errorMessage, attempts, resolution, capturedAt,
payload and skipDetail.
9.4 Secrets¶
Served by SecretResource. Named per-tenant secrets referenced by connector configs
(secret:NAME, resolved by SecretResolver). Values are never returned — no read endpoint exposes
value; reads return metadata only (name, description, timestamps, rotatedAt).
| Method | Path | Permission | Contract |
|---|---|---|---|
GET |
/api/platform/secrets |
lm_integrator |
List secrets (metadata only) |
GET |
/api/platform/secrets/{name} |
lm_integrator |
Describe one secret (metadata only); 404 if unknown |
POST |
/api/platform/secrets |
lm_integrator + lm_admin |
Create {name, value, description?}; 201, 409 on duplicate name |
PUT |
/api/platform/secrets/{name} |
lm_integrator + lm_admin |
Rotate the value (sets rotatedAt); 204 |
DELETE |
/api/platform/secrets/{name} |
lm_integrator + lm_admin |
Soft-delete; 204, 404 if unknown |
Values are encrypted at rest in-database with pgcrypto (pgp_sym_encrypt, AES-256)
under lumnik.secret.master-key. Create/rotate/delete each emit a platform event.
9.5 Data Provenance — Row Behind a Citation¶
GET /api/platform/data/row/{hash}
Served by DataRowResource — this is what lm data row HASH calls. Drills down from a
RAG citation's row_hash to the raw source row: chunk metadata → mapping →
dynamic table → SELECT * on the _row_hash match. Returns the full row as a flat
JSON object (all values stringified); 404 if the hash resolves to nothing in the
current tenant. Permission: lm_integrator; the dynamic-table lookup is tenant-scoped.
9.6 Migrations¶
GET /api/platform/migrations
Served by MigrationAdminResource. Read-only Flyway status, one entry per schema:
{ "schema": "core", "currentVersion": "42", "pending": 0, "lastApplied": "..." }
There is no apply endpoint — migrations are applied at startup by
PlatformFlywayRunner; this endpoint only reports drift. Permission: lm_integrator.
10. Identity & Tenancy CRUD (/api/tenants)¶
Served by the lumnik-identity module (TenantResource, OrgResource, UserResource).
These endpoints live outside /api/platform/*: they carry no role annotation — any
authenticated caller (valid JWT; 401 otherwise) may call them, and isolation is
enforced by tenant-scoped RLS plus explicit tenant checks in each resource. All deletes
are soft (archive), never hard.
10.1 Tenants¶
| Method | Path | Contract |
|---|---|---|
GET |
/api/tenants |
List active tenants |
GET |
/api/tenants/{id} |
One tenant; 404 if unknown |
POST |
/api/tenants |
Create {code, name, description?}; 201, 409 on duplicate code |
PUT |
/api/tenants/{id} |
Update name/description |
DELETE |
/api/tenants/{id} |
Deactivate (soft delete); 204 |
10.2 Organizations¶
| Method | Path | Contract |
|---|---|---|
GET |
/api/tenants/{tid}/orgs |
List active orgs |
GET |
/api/tenants/{tid}/orgs/{id} |
One org; 404 if unknown or wrong tenant |
GET |
/api/tenants/{tid}/orgs/tree?root=&depth= |
Org tree (default depth 3), optionally rooted |
POST |
/api/tenants/{tid}/orgs |
Create; 409 on duplicate code; emits org.created |
PUT |
/api/tenants/{tid}/orgs/{id} |
Update; emits org.updated (+ org.moved on parent change) |
DELETE |
/api/tenants/{tid}/orgs/{id} |
Deactivate; emits org.archived; 204 |
10.3 Users¶
| Method | Path | Contract |
|---|---|---|
GET |
/api/tenants/{tid}/users |
List active users |
GET |
/api/tenants/{tid}/users/{id} |
One user; 404 if unknown or wrong tenant |
POST |
/api/tenants/{tid}/users |
Create {login, name, email?, description?}; 409 on duplicate login |
PUT |
/api/tenants/{tid}/users/{id} |
Update profile (name, email, description) |
POST |
/api/tenants/{tid}/users/{id}/unlock |
Unlock a locked account |
DELETE |
/api/tenants/{tid}/users/{id} |
Deactivate (soft delete); 204 |
lumnik stores no credentials: authentication is Keycloak/OIDC, and users are
JIT-provisioned on (idp_issuer, idp_subject) at first login. There is no
login+password endpoint.
Role management follows the same pattern under
/api/tenants/{tid}/roles, /api/tenants/{tid}/users/{uid}/roles/{roleId} (assign/remove)
and /api/tenants/{tid}/roles/{roleId}/orgs/{orgId} (grant/revoke org access).
Onglet Hooks & Workflows¶
Deux sous-vues via un sélecteur segmenté Hooks / Workflows. Accessible à /admin/:tid/hooks.
Sous-vue Hooks¶
Liste tous les hooks chargés par la plateforme (depuis tous les META-INF/hooks.yaml).
Colonnes : id, module, cible, événements, priorité, actif. Un toggle par ligne
enregistre un override per-tenant dans core.hook_tenant_override. Cache TTL 60 secondes —
le changement est effectif au pire après 60 s pour les autres threads. Cliquer l'icône info
ouvre HookDetailDialog avec les détails complets du hook.
Sous-vue Workflows¶
Liste les cycles de vie déclarés, par tenant. Jusqu'au 2026-08-04, cette section décrivait
un mécanisme pré-pivot — scan classpath d'enums Java annotées @DocumentWorkflow (résolues
vers des entités comme Repair, module depuis longtemps supprimé), diagramme Mermaid.js,
comptage de hooks par transition — entièrement supprimé (WorkflowRegistry ne scanne plus
rien). Un workflow est aujourd'hui un manifeste kind:Workflow (YAML), déclaré par tenant et
appliqué via POST /api/workflows/apply. La référence complète — champs, sémantique des
alias, provenance, rapport de data-truth — vit dans
Workflows — declare, pas ici. Il n'existe plus de rattachement
hooks ↔ transitions : WorkflowRegistry ne consulte plus HookRegistry.
Endpoints¶
| Method | Path | Permission |
|---|---|---|
GET |
/api/platform/workflows |
lm_admin ou lm_superadmin |
GET |
/api/platform/workflows/{name} |
lm_admin ou lm_superadmin |
POST |
/api/platform/hooks/{hookId}/toggle?tenantId= |
lm_admin |
Navigation¶
| Path | Vue |
|---|---|
/admin/:tid/hooks |
Onglet Hooks & Workflows (sélecteur Hooks / Workflows) |
Onglet Listes¶
Gestion des listes de valeurs métier — core.list_definition (l'entête) +
core.list_value (les valeurs). Deux catégories :
| Origine | Drapeau | Permissions |
|---|---|---|
| Système | system_owned=true |
Renommer libellé OK ; ajouter/supprimer valeurs interdit |
| Métier | system_owned=false |
CRUD complet (valeurs désactivables, non supprimées) |
Pattern Value/Name (héritage iDempiere)¶
value= code technique, immuable. Utilisé dans le code, les imports, les références croisées. Exemple :VIP.name= libellé d'affichage, éditable. Exemple :Client VIP. Les tenants peuvent renommer une valeur sans casser le code.
Pas d'i18n pour ce scope — name est plain VARCHAR. Future plan : table list_value_label
indexée par locale si nécessaire.
@ListMirror — déclarer un miroir d'enum Java¶
@ListMirror(code = "repair_status", name = "Statuts de réparation")
public enum RepairStatus { ATTENTE, EN_COURS, TERMINE, ANNULE; }
ListMirrorBootstrapper lit l'annotation au boot et crée (ou met à jour) pour
chaque tenant la liste repair_status (system_owned=true) avec un list_value
par constante. Les libellés sont initialisés à la valeur (ATTENTE → "ATTENTE")
et peuvent être renommés ensuite par tenant.
@ListMirror peut être posée sur n'importe quel enum, indépendamment de tout autre usage
qu'il en est fait par ailleurs.
Événements émis¶
list.value.added {listCode, value, name}
list.value.updated {listCode, value, oldName, newName}
list.value.removed {listCode, value}
Disponibles dans le catalogue (GET /api/platform/events). Les hooks et intégrations
peuvent s'y abonner normalement.
Endpoints¶
GET /api/tenants/{tid}/lists (lm_admin)
POST /api/tenants/{tid}/lists (lm_admin) — system_owned forcé à false
GET /api/tenants/{tid}/lists/{id} (lm_admin)
PUT /api/tenants/{tid}/lists/{id} (lm_admin) — libellé/description seulement sur system_owned
DELETE /api/tenants/{tid}/lists/{id} (lm_admin) — 403 sur system_owned
GET /api/tenants/{tid}/lists/{id}/values (lm_admin)
POST /api/tenants/{tid}/lists/{id}/values (lm_admin) — 403 sur system_owned
PUT /api/tenants/{tid}/lists/{id}/values/{vid} (lm_admin)
DELETE /api/tenants/{tid}/lists/{id}/values/{vid} (lm_admin) — désactivation, pas suppression
Sécurité¶
- RLS tenant-scopée sur
core.list_definitionetcore.list_value. tenant_iddupliqué surlist_valuepour permettre la policy RLS sans jointure. Le bootstrapper et leListResourcedoivent garder la cohérence.
Futur (out of scope)¶
- i18n des libellés : table
list_value_label(list_value_id, locale, name). - Workflows dynamiques : entités futures référencent
list_value.idcomme statut @DocumentWorkflowlu dynamiquement depuis la DB. Pas pour ce plan.