Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/AOAG-DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -622,11 +622,11 @@ or an NLB with:
- **One VIP per inbound MLLP port, with the health check a TCP connect to that port.** Only the
leader binds it, so the check passes only on the active engine and the VIP follows engine
failover passively, including a failover to the DR-DC standby.
- **L7 alternative.** Probe `GET /cluster/status` and require `role == "primary"`. This needs a
least-privilege `monitoring:read` bearer token injected by the LB, because unauthenticated probes
get 401/403 on *every* node and black-hole traffic. Mint one by creating a dedicated local
service account with a `monitoring:read`-bearing role and using its bearer token
([`SECURITY.md`](SECURITY.md), "Roles & permissions" / "Sessions"). The tokenless `/health`
- **L7 alternative.** Probe `GET /cluster/status` and require `role == "primary"`. This needs the
probe to carry a bearer token, because unauthenticated probes get 401/403 on *every* node and
black-hole traffic. The engine mints no static API token: the only bearer is a sign-in session
token of an account holding `monitoring:read`, which expires, so something beside the LB must
refresh it ([`CLOUD-DEPLOYMENT.md`](CLOUD-DEPLOYMENT.md) section 3.1 describes the constraint). The tokenless `/health`
returns 200 on every node and **cannot** select the primary; there is no tokenless primary-only
endpoint.
- **Set the probe interval comfortably above the ~1 s graph-reconcile interval** (e.g. 10 s) so
Expand Down
2 changes: 1 addition & 1 deletion docs/ASVS-L2-PHASE0-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,7 @@ tables in [`CONNECTIONS.md`](CONNECTIONS.md) §"Resource management & limits" (A
| Store — SQLite (the default backend) | local | **no network socket** — a local database file (WAL) | n/a; `[store].ssl_root_cert` is **rejected** for SQLite | filesystem ACLs; at-rest encryption is the store DEK, not a transport credential | no — a local path | `[store].backend = sqlite`, `[store].path` |
| Store — SQL Server | outbound | TDS over ODBC Driver 18, **default port 1433** | `encrypt` default true, `trust_server_certificate` default false; optional `ssl_root_cert` pins the server certificate via the Driver-18.1 `ServerCertificate` keyword, sent with `Encrypt=strict` (SQL Server 2022 or later); optional `multi_subnet_failover` for an AOAG listener | SQL / Integrated / Entra, with an opt-in `require_managed_identity` precondition (default off) | **yes** — `[store].server` | `[store].backend = sqlserver`, `[store].server`, `[store].port`, `[store].auth`, `[store].pool_size`, `[store].connect_timeout`, `[store].command_timeout`, `[store].warm_pool` |
| Store — Postgres (asyncpg) | outbound | TCP; the model's `port` default is 1433 and a **left-at-default value is remapped to Postgres's conventional 5432** | TLS built from `encrypt` / `trust_server_certificate`; `ssl_root_cert` loads `ssl.create_default_context(cafile=…)`, so chain **and** hostname are still verified | username + password only — Postgres has **no managed-identity mode**, so it cannot satisfy `require_managed_identity` | **yes** — `[store].server` | `[store].backend = postgres`, plus the same `[store]` connection keys as SQL Server |
| Active Directory — login binds | outbound | LDAPS (TLS) by default; a plain `ldap://` bind requires the explicit `ad_allow_insecure_ldap` opt-in, which is honoured only under `[security].enforcement = warn` and inert under `enforce` (vault BACKLOG #2354) | `ad_tls_verify` default true (`CERT_REQUIRED`); `ad_tls_verify=false` now **refuses at construction**, with no escape (CHANGED 2026-10-12 by vault BACKLOG #3230; until then `MEFOR_ALLOW_INSECURE_TLS` released it); `ad_tls_ca_cert_file` anchors an internal CA without disabling verification | a service-account simple bind (`ad_bind_dn` + `ad_bind_password` / `ad_bind_password_secret`), then a **second bind as the user**, then 1–2 SUBTREE searches | **yes** — `[auth].ad_server` | `[auth].ad_server`, `ad_domain`, `ad_bind_dn`, `ad_user_search_base`, `ad_group_search_base`, `ad_use_nested_groups`, `ad_connect_timeout`, `ad_receive_timeout` |
| Active Directory — login binds | outbound | LDAPS (TLS) by default; a plain `ldap://` bind requires the explicit `ad_allow_insecure_ldap` opt-in, which is honoured only under `[security].enforcement = warn` and inert under `enforce` (vault BACKLOG #2354) | `ad_tls_verify` default true (`CERT_REQUIRED`); `ad_tls_verify=false` now **refuses at construction**, with no escape (CHANGED 2026-10-12 by vault BACKLOG #3230; until then `MEFOR_ALLOW_INSECURE_TLS` released it); `ad_tls_ca_cert_file` anchors an internal CA without disabling verification | **no sign-in binds as the user**: directory password sign-in is retired (BACKLOG #1137), and the engine refuses it before any bind. At least a Windows SSO or OIDC sign-in, a directory account's second-factor and enrolment legs, and an OIDC step-up look the principal up through a service-account simple bind (`ad_bind_dn` + `ad_bind_password` / `ad_bind_password_secret`) and 1–2 SUBTREE searches (`resolve_principal`). A **second bind as the user** happens only on a directory account's password step-up (`POST /me/reauth`, console `POST /ui/reauth`; `_reauth_ad`), after a service-account search for the entry; an absent or disabled entry still gets a bind of the typed password against a DN that cannot exist (`_equalizing_bind`), which equalizes the code path; its docstring says it does not claim equal wall-clock time | **yes** — `[auth].ad_server` | `[auth].ad_server`, `ad_domain`, `ad_bind_dn`, `ad_user_search_base`, `ad_group_search_base`, `ad_use_nested_groups`, `ad_connect_timeout`, `ad_receive_timeout` |
| Active Directory — directory session reconciler (ADR 0079) | outbound (periodic) | the same LDAPS hop, off the login path | as above | the service-account bind only — one `resolve_principal` bind per signed-in directory user per pass | same server | `[auth].ad_session_recheck_seconds` (default 300 s, floored at 60 s; 0 = OFF), `ad_session_recheck_max_users`, `ad_session_recheck_strikes`, `ad_session_revoke_max`, `ad_session_revoke_max_fraction` |
| Kerberos / SPNEGO browser SSO | n/a — **the engine opens no socket** | one SPNEGO *server* step against the host's SSPI (Windows) or GSSAPI/krb5 (Linux) provider using the local keytab / machine credential; any KDC traffic belongs to the OS provider | n/a — the engine terminates nothing here | the browser's SPNEGO ticket, validated by the OS provider | no — the host's realm / keytab | `[auth].kerberos_enabled` (experimental, off by default), `[auth].kerberos_spn` |
| OIDC IdP — token endpoint (ADR 0142) | outbound | HTTPS POST to the operator-**pinned** endpoint; port from the URL. There is **no `.well-known` discovery** — every endpoint is pinned, so no attacker-influenced URL exists | `ssl.create_default_context(cadata=...)` over the bytes the anchor check read from `oidc_tls_ca_cert_file` (never a second read of the file, BACKLOG #1142), or the OS store, **always** `check_hostname=True` + `CERT_REQUIRED` (no verify-off escape by design); redirects are never followed | the confidential-client secret, or under `private_key_jwt` a signed client assertion and no secret (BACKLOG #296), plus the authorization code and the PKCE verifier | **yes** — `[auth].oidc_token_endpoint` | `[auth].oidc_token_endpoint`, `oidc_client_id`, `oidc_client_secret` (env `MEFOR_AUTH_OIDC_CLIENT_SECRET`) or `oidc_client_secret_ref`, or `oidc_token_endpoint_auth_method` with `oidc_client_private_key` / `_ref`, `oidc_allowed_endpoints`, `oidc_tls_ca_cert_file` |
Expand Down
17 changes: 14 additions & 3 deletions docs/CLOUD-DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,20 @@ VIP follow failover **passively**, with no engine VIP code.
by the `monitoring:read` permission (`Depends(require(Permission.MONITORING_READ))` in
[`api/app.py`](../messagefoundry/api/app.py)), so an unauthenticated LB health probe gets `401/403` on
**every** node (the leader included) and the LB would mark all backends unhealthy and black-hole MLLP.
To use it, the LB must **inject a static bearer token / session header** into the probe (mint a
least-privilege `monitoring:read` token; rotate it) — and **many L4 LBs cannot send auth headers at
all**. If your LB cannot authenticate the probe, use the **TCP-connect-to-2575** default above (it needs
To use it, the probe must carry a bearer token. The engine has **no static API token and no route
that mints one**: the only bearer is a sign-in session's token, and that session ends at its
absolute limit at the latest ([SECURITY.md](SECURITY.md#sessions)). So something beside the LB
must sign in a dedicated account non-interactively (a local password, or Kerberos through
`POST /auth/negotiate`; directory password sign-in is retired) and hand the LB a fresh token
before each session ends. Give that account an administrator-defined custom role holding only
`monitoring:read` ([SECURITY.md](SECURITY.md#custom-roles-adr-0045)); the narrowest built-in
role, `viewer`, also grants `messages:read`. A new local account must first replace its
engine-generated password before its session can reach `/cluster/status`. Under the shipped
`[security].require_mfa` and `require_mfa_scope` the account also owes a second factor at each
sign-in: hand over the token of the finished sign-in, which is the one `POST /auth/login` returns
for a combined sign-in (password and TOTP code in one request), or the one `POST /auth/mfa-verify`
returns after a two-step sign-in, because that call re-keys the session and retires the earlier
token. **Many L4 LBs cannot send auth headers at all**. If your LB cannot authenticate the probe, use the **TCP-connect-to-2575** default above (it needs
no auth and works on any L4 LB); there is **no** tokenless primary-only HTTP endpoint to point an
unauthenticated L7 check at. The `/health` liveness endpoint *is* tokenless, but it is 200 on every node
and so cannot select the primary.
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ document ([SECURITY-DOCS-POLICY.md](SECURITY-DOCS-POLICY.md)).
| `session_idle_timeout_minutes` | | | **→ moved to `[security].sign_out_after_idle_minutes`** (ADR 0118) — set it there; no longer accepted in `[auth]`. |
| `session_absolute_hours` | | | **→ moved to `[security].max_session_hours`** (ADR 0118) — set it there; no longer accepted in `[auth]`. |
| `max_sessions_per_user` | int | 5 | cap concurrent sessions per user (ASVS 7.1.2; `0` = unlimited); a login beyond the cap revokes the user's oldest live session; only live sessions count, and the same login revokes the user's sessions already past the idle or absolute limit. Sign-ins that still owe a second factor are counted apart: see the *Concurrent session count* row in `docs/SECURITY.md` |
| `step_up_max_age_seconds` | int | 300 | **step-up re-verification window** (ASVS 7.5.3): a highly sensitive operation requires the session to have re-verified its credential — at login or via `POST /me/reauth` — within this many seconds. A code accepted at `POST /auth/mfa-verify` (or the console's `POST /ui/mfa`) opens the window too, except on a session the OIDC sign-in minted, whose window opens only at the IdP (BACKLOG #2142). A **local** login that owes no second factor usually counts as the first verification (the sudo-timestamp model). The exception is a sign-in from an address the account's known-address record has not seen in the last 90 days, by an account with a baseline. An account has a baseline once it has finished a sign-in, or once its record holds a row of any age. That session is born with no window, which is the first-seen address challenge (BACKLOG #288). A sign-in that stopped at its factor adds nothing to the record; the *First-seen sign-in address* rows of Table A in [SECURITY.md](SECURITY.md) list the events that do, and a step-up or an enrolment writes only in some cases. A step-up from that address passes the challenge; signing in again does not. The check fails open, and the window opens, when the sign-in carries no client address or the account's known-address record cannot be read. A sign-in that proves the password and a TOTP code in one request counts from any address (ADR 0197). A directory login (Windows SSO by either route, or OIDC) does not: it is born with no window, so its first sensitive action asks for a step-up, or for its second factor where it owes one (BACKLOG #1144). An OIDC session that proves its factor still owes the IdP step-up |
| `step_up_max_age_seconds` | int | 300 | **step-up re-verification window** (ASVS 7.5.3): a highly sensitive operation requires the session to have re-verified its credential — at login or via `POST /me/reauth` — within this many seconds. A code accepted at `POST /auth/mfa-verify` (or the console's `POST /ui/mfa`) opens the window too, except on a session the OIDC sign-in minted, whose window opens only at the IdP (BACKLOG #2142). A **local** login that owes no second factor usually counts as the first verification (the sudo-timestamp model). The exception is a sign-in from an address the account's known-address record has not seen in the last 90 days, by an account with a baseline. An account has a baseline once it has finished a sign-in, or once its record holds a row of any age. That session is born with no window, which is the first-seen address challenge (BACKLOG #288). A sign-in that stopped at its factor adds nothing to the record; the *First-seen sign-in address* rows of Table A in [SECURITY.md](SECURITY.md) list the events that do, and a step-up or an enrolment writes only in some cases. A step-up from that address passes the challenge; signing in again does not. The check fails open, and the window opens, when the sign-in carries no client address or the account's known-address record cannot be read. A combined sign-in (the password and, in the same request, a TOTP code on an account with TOTP enrolled or a passkey assertion as [SECURITY.md](SECURITY.md#webauthn-passkeys-wp-14b-adr-0068) describes, ADR 0197 Amendment A) counts from any address (ADR 0197). A directory login (Windows SSO by either route, or OIDC) does not: it is born with no window, so its first sensitive action asks for a step-up, or for its second factor where it owes one (BACKLOG #1144). An OIDC session that proves its factor still owes the IdP step-up |
| `require_action_step_up` | bool | `true` | **action-bound step-up** ([ADR 0077](adr/0077-action-bound-step-up.md); ASVS 7.5.1/8.2.4). On by default: a fixed set of routes, four JSON routes and their console twins only under the condition below, requires a fresh proof **bound to that specific action** (`POST /me/reauth` with a matching `purpose`, single-use) instead of riding the session-wide `step_up_max_age_seconds` window. On the JSON API the set is `POST /me/mfa/enroll`, `POST /me/mfa/confirm`, `DELETE /me/mfa`, `DELETE /me/sessions` and `DELETE /me/sessions/{session_id}`, plus the administrator's `PATCH /users/{user_id}`, `POST /users/{user_id}/reset-password`, `POST /users/{user_id}/reset-mfa`, `PUT /users/{user_id}/federated-identity` and `DELETE /users/{user_id}/federated-identity`. Four group-wide saves join the set conditionally (vault BACKLOG #3401): `PUT /ad-group-map`, `PUT /ad-group-scope-map`, `PUT /roles/custom/{role_id}` and `DELETE /roles/custom/{role_id}` take a proof bound to `save_ends_own_sessions` **only when the save would end the caller's own sessions**, as a map save would for a caller whose account is a directory account, or a role save for a caller who holds that role. Any other save of those four keeps the session-window step-up. Since vault BACKLOG #2625 the set also holds the injection and bulk-export routes: `POST /messages/{message_id}/resend`, `POST /messages/{message_id}/edit-resend`, `POST /uploads/{file_id}/resend`, `GET` and `POST /messages/export`, `POST /connections/{name}/purge` and `POST /config/reload`. The console binds the same actions, and passkey enroll and delete too. It closes the most-exploitable default: a session hijacked inside the 300 s login-seeded window could otherwise bind an attacker's authenticator, deliver a fabricated message to a partner, or export bodies in bulk, with no fresh proof. It changes **only** that set; every other step-up route, the rest of the admin surface and replay among them, keeps the session-window step-up. So `false` takes the bound proof off message injection, bulk export, purge and config reload as well as off the factor and admin routes. `false` reverts to the legacy session-window behaviour (0.2.x semantics), the documented org opt-out. **One refusal survives the opt-out and no setting reaches it:** an MFA-pending session on an account that **already holds** a second factor cannot bind a new one or end sessions, on either surface — it must prove the existing factor first. Without that bound, `false` would turn a config knob into an account-takeover path, because the enrollment ceremonies mark a session MFA-satisfied on success. Enrolling a **first** factor is untouched |
| `password_min_length` | int | 15 | local-password policy — ASVS 5.0-aligned, length-first |
| `password_require_uppercase` / `password_require_lowercase` / `password_require_digit` / `password_require_symbol` | bool | `false` | character classes — **opt-in**, each independently (ASVS 5.0 forbids mandatory composition); turn one on only for a legacy standard that still mandates it |
Expand Down
8 changes: 6 additions & 2 deletions docs/REMOTE-CONSOLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,12 @@ write calls the same JSON handler, RBAC permission and audit path the API uses
whoever holds the permission. Plan an exposure against this list, not against the dashboard's
read-only-looking front page.

Every row is a `POST`. **Step-up** means the operator is re-verified (password, plus MFA where
enrolled) before the action runs — a stale window is redirected to `/ui/reauth` rather than refused.
Every row is a `POST`. **Step-up** means the operator re-proves a credential before the action runs: a local
account re-enters its password, a directory account signed in by Windows SSO re-enters its directory
password (a live bind), and an OIDC session goes back to its IdP and is never asked for a password.
The step-up asks for a TOTP code or a passkey only from a session that has not yet met its second
factor; a factor proved at sign-in is not asked for again. A stale window is redirected to
`/ui/reauth` rather than refused.
**Dual control** applies only where `[approvals]` is enabled (it is **off by default**): the action is
then held for a second, *distinct* approver holding `approvals:approve` instead of executing inline.

Expand Down
Loading
Loading