The connector’s HTTP API can change what data leaves your plant, so it is locked down by default.
Network bind
The listener binds connector.bind_addr:connector.health_check_port, default
127.0.0.1:3000. To reach it remotely, either front it with a TLS reverse proxy (nginx,
Caddy, Cloudflare Tunnel) or set bind_addr: "0.0.0.0" (env OPEX_BIND_ADDR) behind a
firewall. OpexFlow itself does not terminate TLS.
While bound to loopback the Host header must be a loopback name (or a host from
cors_origins); a request whose Origin is neither same-origin nor allow-listed gets
403.
API token
Every /api/* route, /metrics and the MCP endpoint (/mcp) requires a credential: a bearer token,
or a browser session from SSO:
Authorization: Bearer <api_token>Only GET /api/health, GET /api/ready and the login endpoints (/api/auth/config, /api/auth/oidc/*) are
public. Errors are 401 {"error":"unauthorized"}. Browsers cannot set headers on WebSockets, so
/api/ws/live, /api/ws/flow and /api/ws/events also accept ?token=<token>; the query token is
honored on those paths only. The bearer can be the api_token (an admin) or a named token (below).
Where the token comes from:
connector.api_tokenin config (supports${ENV}), or envOPEX_API_TOKEN.- Otherwise a random 32-byte hex token is generated on first start and stored at
<data_dir>/api_token(mode0600). The log prints the path, never the token.
sudo cat /var/lib/opex-flow/api_token # systemd
docker compose exec connector cat /data/api_token
curl -H "Authorization: Bearer $(cat api_token)" http://127.0.0.1:3000/api/statusThe UI offers Sign in with SSO when auth.oidc is configured and keeps the token form as a fallback (kept in
browser localStorage, asked again on a 401). Rotate by changing api_token (or deleting the file) and restarting.
The MCP endpoint lets an AI agent deploy flows and drive printers, so the token is as powerful
there as in the dashboard. On a client’s production connector set connector.mcp.read_only: true
(or enabled: false); see MCP server.
CORS
Same-origin only: no CORS headers are sent. To call the API from another web origin (e.g. the Vite dev server) list it explicitly:
connector:
cors_origins: ["https://ops.example.com", "http://localhost:5173"]Secrets in responses
GET /api/config returns the running config with secrets replaced by ***
(hmac_secret, api_token, passwords, tokens, keys, credentials embedded in URLs, and the
token in the push_endpoint path).
Keep secrets in ${ENV} variables or a root-only EnvironmentFile, not in the YAML.
Runtime-added sources
POST /api/sources lets an authenticated caller add sources, so it is constrained:
http/https URLs only, loopback / link-local / metadata / multicast addresses are always
refused, private (RFC1918) addresses are allowed unless
connector.allow_private_sources: false, DB queries must be a single read-only
SELECT/WITH, and serial ports must be /dev/tty* or COM*.
Scripts and http nodes
Pipeline http nodes are deny-by-default: only hosts in transform.executor.http_host_allowlist
are reachable, and ${ENV} expansion in node specs is limited to names in
transform.executor.http_env_allowlist (default none).
Credentials for ERP APIs go in the http node’s auth block as ${ENV_NAME} references only
(a literal secret is rejected on Deploy), so flow.json, GET /api/flow, exports and MCP
drafts never contain one. Allowlisting a variable lets any flow author with Deploy access use
it in a request to an allowlisted host, so allowlist only credentials meant for the ERP hosts
you allowlisted. See HTTP node.
Signed webhooks
POST /api/hooks/{name} is the only route exempt from the bearer token and from the Host and
Origin checks. External systems (DOKU, shop and payment platforms) cannot hold the API token, so each
call is authenticated by the HMAC signature of its webhook source node instead
(Signed webhooks). Every other /api/* route stays token-protected.
- Per-hook HMAC. A hook without a signature scheme fails the deploy; a missing, wrong or stale
signature, or a wrong DOKU
Client-Id, answers401 {"error":"invalid signature"}, is counted asbad_signatureinengine.webhooks[]ofGET /api/status, and is never routed. The reason is only logged and kept aslast_reject, not returned to the caller. - Secrets only via
${ENV}or${secret:name}.webhook.signature.secretmust be exactly one${ENV_VAR}of the connector process or one${secret:name}reference (Data protection); it is resolved at deploy, never stored inflow.json. A missing or empty value fails the deploy. - Unknown hook =
404and nothing of the body is read. A disabled hook is unknown. - Body limit.
max_body_bytes(default 1 MiB, at most 16 MiB) answers413; a body not received within 15 s answers408. - Replay. Set
tolerance_secs(e.g. 300) so a captured request is rejected once its timestamp is stale, and add a dedupe node on the sender’s request id. - Proxy. Behind a reverse proxy expose only
/api/hooks/*to the internet, not the rest of/api. If the proxy rewrites the path, set the DOKUrequest_targetto the path DOKU signs.
Users, roles and single sign-on
Every call has an identity with one of four roles, ordered viewer < operator < editor < admin;
a role includes every lower one. One table in the connector (iam::policy) maps every /api route and
every MCP tool to its minimum role, and a test fails when a route is missing from it. A caller below the
role gets 403 {"error", "required_role", "role"}; an /api route that is not in the table needs admin.
| Role | May | Routes (examples) |
|---|---|---|
viewer |
read everything | GET status, flow, devices, history, traces, dead letters, printers, lookups, catalogue, reports, live WebSockets; dry-run checks (flow/validate, script/validate, events/{name}/validate) |
operator |
viewer + run the plant side | dead-letter replay / discard / correct (single and bulk), printer jobs and status probe, sources/{key}/inject, ingest, push-queue replay / purge, obs/test |
editor |
operator + change the configuration | deploy (PUT /api/flow), flow/test, runtime sources, printers (create / edit / delete / import), event catalogue, lookup tables, OpenAPI specs, tag mapping, AI script generation |
admin |
editor + control the control plane | users, API tokens, GET /api/config, config/reload, license activate / revalidate / deactivate, audit log, secrets |
Three kinds of caller exist:
- Browser users sign in with OpenID Connect (below) and get a session cookie.
- Named API tokens for machines and agents (below): a role, an optional expiry.
- The legacy
api_token(above) keeps working exactly as before and is an admin token namedapi-token, so existing installs, scripts and MCP clients do not break. Treat it as the break-glass credential: keep it in anEnvironmentFile, rotate it, and prefer named tokens for everything routine.
OIDC login (SSO)
Any OpenID Connect provider works (Keycloak, Microsoft Entra ID, Google, Okta, …). OpexFlow is the relying
party with the authorization-code flow + PKCE (S256); the identity provider (IdP) authenticates the person
and does the multi-factor authentication (RFP ID-05): enforce MFA there. Register
https://<host>/api/auth/oidc/callback as the redirect URI.
auth:
session_ttl_secs: 28800 # absolute session lifetime (default 8 h)
oidc:
issuer: https://keycloak.example.com/realms/plant # discovery: <issuer>/.well-known/openid-configuration
client_id: opex-flow
client_secret: ${OIDC_CLIENT_SECRET} # empty = public client (PKCE only)
scopes: [openid, profile, email] # add offline_access for IdP refresh tokens
role_claim: realm_access.roles # claim name or dotted path; string or list
role_map: # IdP value -> opex-flow role; highest match wins
opex-viewers: viewer
opex-operators: operator
opex-editors: editor
opex-admins: admin
# default_role: viewer # for users that match nothing (else: refused)
redirect_url: https://flow.example.com/api/auth/oidc/callback # recommended; else derived from Host
require_mfa_for_admin: true # refuse admins whose id_token amr lacks mfa
mfa_values: [mfa] # amr values that count as MFAWhat happens: the login page’s Sign in with SSO button opens GET /api/auth/oidc/login, which redirects to
the IdP with state, nonce and a PKCE challenge (a short-lived opex_oidc_state cookie ties the callback to
that browser). The callback exchanges the code, then verifies the id_token: signature against the IdP’s JWKS
(RS256 or ES256 only; none and HMAC are refused), iss, aud (azp when several), exp/nbf, and the
nonce. The role comes from role_claim through role_map. Entra ID: put app roles in the roles claim
(role_claim: roles) or use group ids; Google has no role claim, so use default_role and promote people with
the user admin (below); Keycloak: add a realm-roles mapper to the ID token.
- Session cookie
opex_session:HttpOnly,SameSite=Lax,Path=/,Securewhen the redirect URL is https or the proxy sendsX-Forwarded-Proto: https(override:auth.secure_cookie). Only a hash of the session id is stored (<data_dir>/auth.db, mode0600). - CSRF. A cookie-authenticated
POST/PUT/DELETEmust sendX-Opex-CSRF: <value from GET /api/auth/me>(the UI does); cross-siteOrigins are refused as before. Bearer-token calls need no header. - Expiry and refresh. A session ends at
session_ttl_secs. When the IdP returned a refresh token, the next request after the access token’sexpires_inrefreshes it and re-reads the role; if the IdP refuses (user disabled, grant revoked) or the user no longer maps to a role, the session ends. Withoutoffline_accessthe session simply lives to its absolute limit. - MFA for admins.
require_mfa_for_admin: truerefuses an admin login whoseamrhas none ofmfa_values(Entra emitsamr: ["pwd","mfa"]; Keycloak needs anamrmapper or an ACR-based flow).GET /api/auth/mereportsmfa. Named tokens and the legacy token are not interactive and are not subject to it. - Users. Each login creates or refreshes a user row. Admin → Access (or
PUT /api/auth/users/{id}{role_override, disabled}) overrides the IdP role or disables the account; both take effect on the next request.connector.api_tokenis the way back in if the IdP is down. - The OIDC settings are read at startup;
config/reloaddoes not change them.
Named API tokens
Admin → Access → API tokens (or POST /api/auth/tokens {name, role, expires_in_days?}) creates a token like
opx_.... The plaintext is shown once; only its SHA-256 is stored, so a leaked auth.db does not leak usable
tokens. Tokens carry a role and an optional expiry, show last_used_at, and are revoked with DELETE /api/auth/tokens/{id} (effective immediately; the row stays for the history). Use them as
Authorization: Bearer opx_... for scripts, Prometheus scrapes (viewer), CI deploys (editor) and MCP agents
(see MCP server). The WebSockets accept ?token= for them too.
Audit log
Every mutating call and every login is appended to <data_dir>/audit.db (SQLite, append-only: triggers refuse
UPDATE and any DELETE inside the retention window). Rows say who (user name, token name or api-token,
with the kind and role), when, action, target, result (ok, denied or error, with the HTTP
status), the request id (X-Request-Id, echoed in every response; a client may send its own) and the
method/path, and a detail object where it helps: a flow deploy carries a version diff summary (versions, node
ids added / modified / removed, edge counts), a config reload the applied settings. Bodies are never stored, so
secrets, license keys and new tokens never appear.
Audited actions: flow.deploy (also the deploy of the shared draft, with its co_authors), flow.discard, flow.rollback, var.{set,delete}, promotion.{send,receive,approve,reject}, config.reload, source.{add,remove,inject}, printer.{upsert,remove,import,job},
deadletter.{edit,replay,discard,replay_bulk,discard_bulk}, push_dead.{replay,purge},
case.{assign,comment,status,link,bulk,from_report,digest}, approval.{approve,reject,resume}, golive.{request,approve,reject,cancel} (Finance controls),
catalogue.{upsert,bulk_upsert,delete}, lookup.*, mapping.*, openapi.upsert, license.*, alerts.test,
token.{create,revoke}, user.update, secret.{set,delete}, audit.export, auth.login, auth.logout, and
every refused call (result: denied, including failed CSRF checks as auth.csrf). MCP tool calls use the same
action names with detail.via: "mcp" and detail.tool. The high-volume data plane (/api/ingest, /api/hooks)
is not audited per request: its outcome lives in traces and the dead-letter queue.
GET /api/audit?actor=&action=&target=&result=&since=&until=&limit=&before=(admin;actionmatches a name or a dotted prefix,since/untilare epoch ms; newest first, page withnext_before) andGET /api/audit/csvwith the same filters (cells starting with=,+,-or@are prefixed with'). Admin → Audit in the editor shows and exports it.- Retention:
auth.audit.retention_days, default 1825 (5 years) for the RFP’s log-retention requirement (NF-08 / SC-03); a lower value logs a warning at startup. Purging is hourly. - Back it up with the rest of
data_dir(sqlite3 audit.db ".backup out.db"); ship it to your SIEM from the CSV or by backing up the file if you need a copy outside the host.
The dead-letter audit trail uses the same authenticated
identity as its actor; the X-Opex-Actor header and the MCP actor argument are gone.
Not provided
- OpexFlow does not terminate TLS or speak SAML directly; use a SAML-capable IdP (Keycloak, Entra ID) that exposes OIDC to OpexFlow.
- Sessions and named tokens are per node (
auth.db); see High availability. - RP-initiated logout at the IdP is not done: Sign out ends the OpexFlow session only.
Checklist
- Keep
bind_addron loopback or a firewalled interface; use TLS in front. - Run as a non-root service user with
ProtectSystem=strict(see Deployment). - Store
CONNECTOR_HMAC_SECRETand tokens in a0600env file. - Webhook hooks: secrets as
${ENV}only,tolerance_secsset, and only/api/hooks/*reachable from the internet. - Verify every push signature on your receiver (Data contract).
- OPC-UA: server certificates are verified (
trust_all_server_certs: false); trust a server by moving its certificate into<data_dir>/pki/trusted. - Give people SSO accounts with the least role they need and keep the legacy
api_token(admin) for break-glass only; mint a named token per machine. - Enforce MFA at the IdP and set
auth.oidc.require_mfa_for_admin: true; keepauth.audit.retention_daysat 1825 or more and back upaudit.db.