Skip to content

API Reference

HTTP endpoints exposed by this repository's two services — the MQTT auth service (celine.mqtt_auth) and the provisioning service (celine.provisioning).

Base URL

Service Environment URL
MQTT auth Development http://localhost:8009
MQTT auth Docker Compose http://mqtt_auth:8009
Provisioning Docker Compose http://provisioning:8010

The provisioning service has no public URL and must not be given one. It is reachable only from inside the network; see ADR-0007.

Authentication

All endpoints (except /health) require a JWT in the Authorization header:

Authorization: Bearer <jwt-token>

The JWT is validated using the OIDC configuration from celine-sdk (OidcSettings).


POST /user

Authenticate an MQTT client. Called by mosquitto-go-auth on client connect.

Extracts the JWT from the Authorization header, validates it, and returns success if the token is valid.

Response (200):

{"ok": true, "reason": "authenticated"}

Response (403):

{"ok": false, "reason": "missing token"}
{"ok": false, "reason": "invalid credentials"}

POST /acl

Authorize MQTT topic access. Called by mosquitto-go-auth on every publish/subscribe.

Validates the JWT, converts the mosquitto acc bitmask to action names, and evaluates the celine.mqtt.acl Rego policy for each action.

Request Body (JSON):

{
  "clientid": "my-client",
  "topic": "celine/digital-twin/events/pump/pump-001",
  "acc": 2
}
Field Type Description
clientid string MQTT client ID
topic string MQTT topic being accessed
acc int Access bitmask: 1=read, 2=publish, 4=subscribe

Access bitmask values:

Value Permission
1 Read
2 Publish
4 Subscribe
3 Read + Publish
5 Read + Subscribe
7 All

Response (200):

{"ok": true, "reason": "authorized"}

Response (403):

{"ok": false, "reason": "denied"}

POST /superuser

Check if a client has MQTT superuser access. Superusers bypass all ACL checks.

Grants superuser if the JWT contains: - The mqtt.admin scope, OR - The admin group, OR - The mqtt.admin group

Request Body (JSON):

{
  "username": "client-id-or-jwt"
}

Response (200):

{"ok": true, "reason": "superuser"}

Response (403):

{"ok": false, "reason": "not superuser"}

GET /health

Liveness check. No authentication required.

Response:

{
  "status": "healthy",
  "policies_loaded": true,
  "policy_count": 2,
  "packages": ["celine.mqtt.acl", "celine.scopes"]
}

GET /docs

Swagger UI for interactive API exploration.

GET /redoc

ReDoc API documentation.


Error Responses

Code Meaning
200 OK (check ok field for auth result)
403 Authentication/authorization failed
500 Internal error (e.g. policy parse failure)

The MQTT auth endpoints always return a MqttResponse body with ok and reason fields. HTTP status 403 is set alongside ok: false to satisfy mosquitto-go-auth's expected behavior.


Provisioning service

celine.provisioning — the only thing that writes a participant account into the celine realm. Stateless: a retry is another call, and idempotency comes from the keys.

Authentication

A bearer token, as everywhere else. A token that does not verify is 401; a token without the scope is 403. provisioning.admin satisfies any of the scopes below.

Endpoint Scope
PUT /participants/{community}/{key} provisioning.participants.write
POST /participants/{community}/{key}/invitation provisioning.participants.write
POST /participants/{community}/{key}/disable provisioning.participants.write
POST /reconcile/{community} provisioning.reconcile

Scope checks are not what keeps this service safe — its lack of a route is. The scope check is defence in depth, for a caller already inside the network.

svc-onboarding is the only client granted a provisioning.* scope. Onboarding is the single point of access to this service; see scopes-and-permissions.md. A community manager's "Send invitation" and "Reset password" on the dashboard reach POST …/invitation through onboarding's member-keyed routes. svc-community holds onboarding.members.invite for that, never a provisioning scope.

PUT /participants/{community}/{key}

Ensure the account exists, is in the REC organization, and is in its org group.

Request:

{
  "email": "a.person@example.org",
  "first_name": "A",
  "last_name": "Person",
  "locale": "it",
  "invite": true
}
Field Required Meaning
email yes Finds the account, or names a new one
first_name, last_name no Written on a new account. Pass both: an account without them meets Keycloak's "update your profile" form after setting its password (measured on 26.7.3)
locale no it, en or es; anything else is 422. The language of Keycloak's emails. Written on a new account, and on an existing one only if it has none
invite no, default false Have Keycloak email an invitation to set a password, only if the account was created in this call or has no password. Never a reset: that is POST …/invitation with intent password_reset

The address transits and is stored nowhere — not in this service, which keeps no state, and not in the registry, which has no email column. It is on the Keycloak account, which is where an address somebody logs in with belongs.

Response (200):

{
  "user_id": "3f1c…",
  "username": "a.person@example.org",
  "created": true,
  "invitation": "sent",
  "invited": true
}

user_id is the Keycloak uuid, which ../onboarding needs for its dataspace step. username is what the account authenticates as, read back from Keycloak rather than computed — an account that already existed may authenticate under a convention nobody here chose — and it is the value that becomes the registry's Member.user_id. Note the name collision: the registry's user_id column holds a username.

Always 200. A create and a no-op are the same request with the same meaning; created says which happened.

invite never fails the upsert. What happened is in invitation, a reason code to show the operator who approved, and invited is true only for sent:

invitation Meaning
not_requested invite was false
sent Keycloak was asked to email an invitation: UPDATE_PASSWORD + VERIFY_EMAIL, valid CELINE_PROVISIONING_INVITE_LIFESPAN (7 days)
has_password The account already has a password; nothing was sent. A retry after the person set their password lands here
no_email The account has no email address, in every email mode; nothing was sent. Keycloak sends to the account's own address, so the body's email does not stand in for it. Typically an account sync-users made from the registry
not_on_dev_list CELINE_PROVISIONING_EMAIL_MODE=dev and the address is not on EMAIL_DEV_RECIPIENTS; a WARNING names the member
account_disabled The account is disabled; nothing was sent, and it is not re-enabled
cooldown The account was emailed within CELINE_PROVISIONING_INVITE_COOLDOWN, by an upsert or by …/invitation; nothing was sent. Ask again after the cooldown
send_failed Keycloak was asked and did not send it — an SMTP failure (Keycloak's 500 Failed to send execute actions email), a refusal such as an unregistered redirect URI, or a timeout. No cooldown starts, so a retry may send. The upsert itself succeeded

New codes may be added. A consumer shows an unknown one raw rather than failing on it.

The checks run in this order, and the first that applies is the answer: account_disabled, has_password, no_email, not_on_dev_list, cooldown, then the send (sent or send_failed).

One send rule for the upsert and the route: an email goes out only because a caller asked (invite: true here, or POST …/invitation); every send is refused within the cooldown of the last successful one to that account, whichever call made it; a send that failed starts no cooldown; an account with a password is never invited, and one without is never sent a reset; and an account with no email address is never sent anything. So a repeat of the upsert before the person has set a password sends another invitation only once the cooldown has passed. Every link sent stays usable until it expires, even after another one has been used (measured on 26.7.3).

Order of deployment: locale is kept only on a realm with internationalizationEnabled. On a realm without it Keycloak answers 201 and drops the value, so internationalization has to be enabled on a realm before this service writes locale to it.

Completing an invitation creates no session: the person sees Keycloak's "account updated" page with a link back to CELINE_PROVISIONING_INVITE_REDIRECT_URI, and then signs in once with the password they chose.

The registry is not written from here. The caller writes the member row, with the username this call returned — which keeps the registry single-writer and the step order fail-closed: the login exists before the row that keys on it.

POST /participants/{community}/{key}/invitation

Email a member an invitation or a password reset, as the caller names it. The member is resolved through the registry, whose Member.user_id is the username. No password is generated or returned: Keycloak sends the link and the person chooses their own.

Request (required):

{"intent": "invitation"}

The caller names the email, and the service checks it against the account in the same call that sends. A mismatch is refused before any send and before the cooldown, and is never turned into the other email, so the email is always the button a person pressed:

intent Account must actions lifespan Otherwise
invitation have no password UPDATE_PASSWORD, VERIFY_EMAIL CELINE_PROVISIONING_INVITE_LIFESPAN, default 604800 (7 days) 409 has_password
password_reset have a password UPDATE_PASSWORD CELINE_PROVISIONING_RESET_LIFESPAN, default 3600 (1 hour) 409 no_password

A missing body, or an intent other than these two, is 422. A reset is short because Keycloak does not revoke earlier links: a reset email must not be a week-long credential.

Response (200):

{
  "user_id": "3f1c…",
  "username": "ex-00001",
  "invitation": "sent",
  "actions": ["UPDATE_PASSWORD", "VERIFY_EMAIL"],
  "lifespan": 604800
}

invitation is sent, or not_on_dev_list when dev email mode refused the address (nothing was sent).

The checks run in this order, and the first that applies is the answer: community_not_found, member_not_found, account_not_found, account_disabled, has_password / no_password, no_email, not_on_dev_list (a 200), cooldown, then the send (sent or send_failed).

Status code Meaning
404 community_not_found The registry has no community under {community}
404 member_not_found The community has no active member under {key}
404 account_not_found The registry has the member, and the realm has no account for it. Provision it first
409 account_disabled The account is disabled. Checked by this service before Keycloak is asked
409 has_password Intent invitation, and the account already has a password. Nothing sent, no cooldown: send password_reset instead
409 no_password Intent password_reset, and the account has no password. Nothing sent, no cooldown: send invitation instead
409 no_email The account has no email address, in every email mode. Nothing sent
422 No body, or an unknown intent
429 cooldown The same account was emailed within CELINE_PROVISIONING_INVITE_COOLDOWN (default 300 s), by this route or by an upsert. Retry-After says when. In memory and per replica: a double-click guard, not a rate limit
502 send_failed Keycloak did not send it. No cooldown starts, so a retry may send

POST /participants/{community}/{key}/disable

Revoke access. The account, its memberships and everything keyed on its uuid survive — disabling is not deletion, and reversing it is one call.

Response (200):

{"user_id": "3f1c…", "username": "ex-00001", "changed": true}

changed: false means the revocation was already in force, which must not read as one that just happened.

404 with community_not_found, member_not_found or account_not_found, as for the invitation.

POST /reconcile/{community}

Provision every active member the registry holds for one community, then assert that each of them is in the REC's organization. Members that are not active are skipped and not disabled — skipping provisioning and revoking access are different acts.

Response (200):

{"community": "example-rec", "members": 45, "created": 2, "existing": 43, "divergences": []}

Response (500) when the assertion finds anything: the same body, under detail, with the divergences listed, and code: "reconcile_diverged" and a message beside them. 404 community_not_found for a community the registry does not have. Everything checked is something this same call claimed to have done, so a finding is a provisioning call that reported success and had not succeeded. It fails loudly rather than repairing quietly — a 200 with a list nobody reads is how 10 of 45 members ended up outside their own organization with nothing saying so.

What calls this on a schedule is a deployment concern. The route is an entry point and nothing here is a scheduler.

Error responses

Every error but 422 has one body:

{"detail": {"code": "member_not_found", "message": "example-rec has no active member 'ex-00099'"}}

code is stable and machine-readable; branch on it. message is a sentence for a person and not a contract. code is a plain string in the OpenAPI document, not an enum, so a code added later does not break a generated client: treat an unknown one by its status. 422 is FastAPI's own validation body, {"detail": [ … ]}.

Status code Meaning
401 missing_token No bearer token — send one
401 invalid_token It does not verify — renew the credential
403 insufficient_scope Verified, but the caller does not hold the scope — ask for a grant
404 community_not_found The registry has no such community
404 member_not_found The community has no active member under that key
404 account_not_found The member exists, and the realm has no account for it
409 account_disabled An invitation for a disabled account
409 has_password Intent invitation for an account that has a password
409 no_password Intent password_reset for an account that has none
409 no_email A send to an account with no email address
422 The upsert's body is missing the address, or locale is not it, en or es; the invitation's body is missing, or its intent is unknown
429 cooldown An invitation within the cooldown; Retry-After says when
500 reconcile_diverged A reconcile ended with divergences; the report is in detail
502 registry_unavailable The registry could not be read, or is not configured
502 send_failed Keycloak did not send the email: SMTP failure, a refusal such as Invalid redirect uri. for a CELINE_PROVISIONING_INVITE_REDIRECT_URI not registered on oauth2_proxy, or a timeout. Starts no cooldown
502 provisioning_failed Any other dependency failure — a dependency, not this service refusing

A registry 404 counts as community_not_found only when the registry names the community. Its unrouted {"detail": "Not Found"} — a wrong CELINE_PROVISIONING_REGISTRY_URL — stays registry_unavailable, because a consumer may read a 404 as "nothing left to do".

Configuration

CELINE_PROVISIONING_*, besides the registry and OIDC settings:

Variable Default Meaning
CELINE_PROVISIONING_INVITE_REDIRECT_URI unset Where "back to the application" points after an invitation or reset: the webapp root. Must be registered on the client below. Unset, the final page has no link back
CELINE_PROVISIONING_INVITE_CLIENT_ID oauth2_proxy The client the redirect is registered on
CELINE_PROVISIONING_INVITE_LIFESPAN 604800 Seconds an invitation link lasts
CELINE_PROVISIONING_RESET_LIFESPAN 3600 Seconds an operator reset link lasts
CELINE_PROVISIONING_INVITE_COOLDOWN 300 Seconds after a successful send during which any further send to that account is refused: 429 on …/invitation, invitation: cooldown on the upsert. 0 disables it
CELINE_PROVISIONING_EMAIL_MODE dev deliver emails anyone; dev emails only EMAIL_DEV_RECIPIENTS and logs a WARNING for everyone else
EMAIL_DEV_RECIPIENTS empty Comma-separated addresses that may be emailed in dev mode. Also read as CELINE_PROVISIONING_EMAIL_DEV_RECIPIENTS. The local Mailpit relays the same list

dev is the default on purpose: its failure is an invitation that did not go out and says so. Every non-dev deployment sets deliver.