Skip to content

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:

  1. Tenant Management — view all tenants registered on the platform, inspect their details, and impersonate a tenant to act on their behalf.
  2. 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:

  1. UI: Click the "Quitter" button in the red badge.
  2. 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:

  1. Adding an entry to the module's events.yaml
  2. 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:

  1. Matches the event type against all active integrations subscribed to it
  2. Fires one HTTP POST per matching integration
  3. Records the outcome in core.webhook_delivery
  4. 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
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_definition et core.list_value.
  • tenant_id dupliqué sur list_value pour permettre la policy RLS sans jointure. Le bootstrapper et le ListResource doivent 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.id comme statut
  • @DocumentWorkflow lu dynamiquement depuis la DB. Pas pour ce plan.