Skip to content

Security

How the OpexFlow control plane is protected: loopback bind, roles (viewer/operator/editor/admin), OIDC single sign-on with PKCE, named API tokens, append-only audit log, same-origin CORS, redacted config and hardening checklist.

Updated View as Markdown

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:

  1. connector.api_token in config (supports ${ENV}), or env OPEX_API_TOKEN.
  2. Otherwise a random 32-byte hex token is generated on first start and stored at <data_dir>/api_token (mode 0600). 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/status

The 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, answers 401 {"error":"invalid signature"}, is counted as bad_signature in engine.webhooks[] of GET /api/status, and is never routed. The reason is only logged and kept as last_reject, not returned to the caller.
  • Secrets only via ${ENV} or ${secret:name}. webhook.signature.secret must be exactly one ${ENV_VAR} of the connector process or one ${secret:name} reference (Data protection); it is resolved at deploy, never stored in flow.json. A missing or empty value fails the deploy.
  • Unknown hook = 404 and nothing of the body is read. A disabled hook is unknown.
  • Body limit. max_body_bytes (default 1 MiB, at most 16 MiB) answers 413; a body not received within 15 s answers 408.
  • 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 DOKU request_target to 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 named api-token, so existing installs, scripts and MCP clients do not break. Treat it as the break-glass credential: keep it in an EnvironmentFile, 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 MFA

What 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=/, Secure when the redirect URL is https or the proxy sends X-Forwarded-Proto: https (override: auth.secure_cookie). Only a hash of the session id is stored (<data_dir>/auth.db, mode 0600).
  • CSRF. A cookie-authenticated POST/PUT/DELETE must send X-Opex-CSRF: <value from GET /api/auth/me> (the UI does); cross-site Origins 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’s expires_in refreshes 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. Without offline_access the session simply lives to its absolute limit.
  • MFA for admins. require_mfa_for_admin: true refuses an admin login whose amr has none of mfa_values (Entra emits amr: ["pwd","mfa"]; Keycloak needs an amr mapper or an ACR-based flow). GET /api/auth/me reports mfa. 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_token is the way back in if the IdP is down.
  • The OIDC settings are read at startup; config/reload does 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; action matches a name or a dotted prefix, since/until are epoch ms; newest first, page with next_before) and GET /api/audit/csv with 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_addr on loopback or a firewalled interface; use TLS in front.
  • Run as a non-root service user with ProtectSystem=strict (see Deployment).
  • Store CONNECTOR_HMAC_SECRET and tokens in a 0600 env file.
  • Webhook hooks: secrets as ${ENV} only, tolerance_secs set, 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; keep auth.audit.retention_days at 1825 or more and back up audit.db.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close