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](/docs/mcp) (`/mcp`) requires a credential: a bearer token,
or a browser session from [SSO](#users-roles-and-single-sign-on):

```
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.

```bash
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](/docs/mcp#safety).

## 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:

```yaml
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](/docs/flows#http-node-authentication-and-posting-mode).

## 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](/docs/flows#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](/docs/data-protection#secrets));
  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](/docs/flows#dedupe-idempotency) 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.

```yaml
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 `Origin`s 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](/docs/mcp)). 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](/docs/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](/docs/operations#dead-letters-correct-and-replay) 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](/docs/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](/docs/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](/docs/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`.