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
16 changes: 15 additions & 1 deletion docs/core/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@ identities with `type service_account` and admit it in relevant type restriction
(`viewer: [user, service_account]`) to put them in the same graph as humans; see
the [DSL construct reference](./fga-guide#direct-assignment--type-restrictions).

An **AI agent acting for a user** (an RFC 8693 delegated token) is checked
against *both* `agent:<client_id>` and `user:<sub>`, and needs both to be
allowed — its effective authority is `perms(agent) ∩ perms(user)`. This applies
to `check_permissions` and `list_permissions` alike, and turns on by declaring
`type agent` in your model; deployments without it are unaffected. See
[Agent Identity & Permissions](../enterprise/agent-identity).

### `check_permissions` — one or many questions

A single check is simply a list of one. Results come back **in order** and echo
Expand Down Expand Up @@ -315,7 +322,14 @@ cookie is used automatically.
- **Metrics.** The engine exports Prometheus metrics — `authorizer_fga_checks_total`
(allow/deny/error), `authorizer_fga_check_duration_seconds`, and
`authorizer_fga_operations_total` — for adoption tracking and denial/error alerting.
See [Metrics & Monitoring → Authorization (FGA) Metrics](./metrics-monitoring#authorization-fga-metrics).
Delegated (agent) callers additionally report
`authorizer_fga_delegated_checks_total{operation,outcome}`, whose `outcome`
label is the only thing that distinguishes *the agent lacks a grant*
(`denied_by_agent`) from *the user genuinely lacks access* (`denied_by_user`) —
and `not_enforced`, which means agent traffic is arriving unconstrained
because the model declares no `agent` type. See
[Metrics & Monitoring → Authorization (FGA) Metrics](./metrics-monitoring#authorization-fga-metrics)
and [Agent Identity & Permissions](../enterprise/agent-identity#observability).
- **Learn the model language.** See the OpenFGA docs:
[modeling guide](https://openfga.dev/docs/modeling/getting-started) and
[configuration language](https://openfga.dev/docs/configuration-language).
Expand Down
27 changes: 27 additions & 0 deletions docs/core/metrics-monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ once. See [Authorization (FGA)](./authorization).
| `authorizer_fga_checks_total` | Counter | `operation`, `result` | Access decisions from `check_permissions`. The headline metric for adoption and denial/error alerting. |
| `authorizer_fga_check_duration_seconds` | Histogram | `operation` | Latency of the client-facing FGA engine reads. |
| `authorizer_fga_operations_total` | Counter | `operation`, `result` | Non-decision FGA operations (model/tuple management, enumeration, reset) by outcome. |
| `authorizer_fga_delegated_checks_total` | Counter | `operation`, `outcome` | Decisions for **delegated** (agent-acting-for-user) callers, and which side of the intersection refused. Ordinary callers never appear here. |

**`authorizer_fga_checks_total` labels:**

Expand All @@ -154,6 +155,32 @@ once. See [Authorization (FGA)](./authorization).
| `operation` | `check_permissions` (each supplied pair is counted individually) |
| `result` | `allowed` · `denied` · `error` (the engine call failed — fail-closed, so the caller was denied) |

**`authorizer_fga_delegated_checks_total` labels:**

| Label | Values |
|---|---|
| `operation` | `check_permissions` · `list_permissions` (see note below) |
| `outcome` | `allowed` · `denied_by_agent` · `denied_by_user` · `not_enforced` |

The `outcome` label is what makes an intersection denial diagnosable:

- **`denied_by_agent`** — the agent has no grant of its own. The user may well
have access. Fix: grant the **agent** a tuple.
- **`denied_by_user`** — the agent had its grant, the delegating user does not.
This is the Confused Deputy being stopped. Do **not** widen the agent; it
cannot help, and the user genuinely lacks access.
- **`not_enforced`** — a delegated caller arrived but the active model declares
no `agent` type, so the request was authorized as the **user alone**.

Only `check_permissions` emits `allowed` / `denied_by_agent` / `denied_by_user`.
`list_permissions` intersects object *sets* rather than folding a per-check
decision, so it can only ever report `not_enforced`.

`not_enforced` is the one to alert on. It is the only outcome that reports a
security property *not* being enforced, and it is silent by construction: the
request succeeds and nothing in the response says the agent was unconstrained.
See [Agent Identity &amp; Permissions](../enterprise/agent-identity#observability).

**`authorizer_fga_check_duration_seconds`** `operation`: `check_permissions` · `list_permissions`. The histogram's `_count` also gives you a call rate per operation for free.

**`authorizer_fga_operations_total`** `operation`: `get_model` · `write_model` · `read_tuples` · `write_tuples` · `delete_tuples` · `list_users` · `expand` · `list_permissions` · `reset`. `result`: `success` · `error`.
Expand Down
49 changes: 41 additions & 8 deletions docs/core/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,13 +313,39 @@ No flags. The protection applies to:
OTP and TOTP secrets are now protected at rest:

- **OTPs (email/SMS one-time codes):** stored as HMAC-SHA256 digests
keyed by `--jwt-secret`. The verifier hashes the candidate and
keyed by `--encryption-key`. The verifier hashes the candidate and
compares constant-time against the stored digest. The plaintext is
only ever held in memory long enough to send the email/SMS body.
- **TOTP shared secrets (authenticator app):** encrypted at rest with
AES-256-GCM (using HKDF-derived keys from `--jwt-secret`) and stored
AES-256-GCM (using HKDF-derived keys from `--encryption-key`) and stored
with an `enc:v1:` prefix.

:::danger Security advisory — affected versions 2.2.1 through 2.4.0-rc.13

In those releases the at-rest key was **not** a separate input. It derived from
`--jwt-secret`, and when a deployment used an asymmetric JWT algorithm
(`RS*`/`ES*` with `--jwt-private-key`/`--jwt-public-key`) and set **no**
`--jwt-secret`, the derivation ran over empty keying material and produced a
**constant compiled into the open-source binary**. Anyone who can read the
repository can compute it.

**If you ran an affected version with `RS*`/`ES*` and no `--jwt-secret`,** a
copy of your database yields recoverable TOTP seeds and every outstanding OTP
digest — including password-reset codes, whose 10⁶ search space is trivial once
the key is known.

Remediation, in order:

1. Set `--encryption-key` to a fresh random value (`openssl rand -hex 32`).
2. Force TOTP re-enrolment for all users — existing ciphertext was written
under the old key and **cannot** be decrypted with the new one. There is no
re-encryption path.
3. Invalidate outstanding password-reset and verification OTPs.

From 2.4.0 the key is its own input and an `RS*`/`ES*` deployment without one
**refuses to start** rather than falling back silently.
:::

### Migration

There is **no flag** for OTP/TOTP migration. The handlers transparently
Expand Down Expand Up @@ -357,16 +383,23 @@ this is a non-issue — there are no other replicas to disagree with.

### Key rotation

Both OTP and TOTP at-rest protection are keyed by `--jwt-secret`.
Rotating `--jwt-secret` will lock out every user with an enrolled TOTP
authenticator until they re-enrol, because the existing ciphertext can
no longer be decrypted. If you must rotate the JWT secret, plan a
TOTP re-enrolment campaign (or a temporary fallback path) before doing so.
Both OTP and TOTP at-rest protection are keyed by `--encryption-key`.
Rotating it will lock out every user with an enrolled TOTP authenticator
until they re-enrol, because the existing ciphertext can no longer be
decrypted. If you must rotate, plan a TOTP re-enrolment campaign (or a
temporary fallback path) before doing so.

`--encryption-key` falls back to `--jwt-secret` when it is not set, which keeps
HMAC (`HS*`) deployments working unchanged — but it means rotating
`--jwt-secret` on such a deployment silently rotates the at-rest key too. Set
`--encryption-key` explicitly to decouple the two; the server warns at startup
when both are set to the same value.

The server logs an explicit error on every TOTP validation that fails
to decrypt:

```
failed to decrypt stored TOTP secret; check that --jwt-secret has not changed since enrollment
failed to decrypt stored TOTP secret; check that --encryption-key (or --jwt-secret, if no encryption key is set) has not changed since enrollment
```

---
Expand Down
27 changes: 24 additions & 3 deletions docs/core/server-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,32 @@ Additional flags:
- **`--refresh-token-expires-in`** (default `2592000`, 30 days): refresh-token
lifetime in seconds. Previously hardcoded — now operator-configurable.

- **`--encryption-key`** (no default): the key used to encrypt secrets **at
rest** — TOTP shared secrets and OTP digests. Separate from the JWT signing
material.

In v2, the `_generate_jwt_keys` mutation is deprecated and returns an error; configure keys **only via flags**.

> **Note on key rotation:** `--jwt-secret` is also used to encrypt TOTP shared
> secrets at rest and to HMAC OTPs. Rotating it will lock out every user with
> an enrolled TOTP authenticator until they re-enrol. See
:::warning Breaking change in 2.4.0 — `--encryption-key`

At-rest encryption no longer derives from `--jwt-secret`. **A deployment using
an asymmetric JWT algorithm (`RS*`/`ES*`) with no `--jwt-secret` will refuse to
start until `--encryption-key` is set.**

HMAC deployments (`HS256`/`HS384`/`HS512`) are unaffected: the key still falls
back to `--jwt-secret`, so no change is required.

This closes a vulnerability in **2.2.1 through 2.4.0-rc.13** where that
fallback produced a publicly known constant for asymmetric deployments — read
the [security advisory](./security#otp-and-totp-at-rest) before upgrading, as
remediation includes forcing TOTP re-enrolment.
:::

> **Note on key rotation:** `--encryption-key` protects TOTP shared secrets at
> rest and HMACs OTPs. Rotating it will lock out every user with an enrolled
> TOTP authenticator until they re-enrol. When it is unset it falls back to
> `--jwt-secret`, so on those deployments rotating the JWT secret rotates the
> at-rest key too. See
> [OTP and TOTP at rest](./security#otp-and-totp-at-rest).

---
Expand Down
Loading