Skip to content

Introduce editor delegation#31

Open
skelz0r wants to merge 10 commits intodevelopfrom
feat/editor-delegation-token
Open

Introduce editor delegation#31
skelz0r wants to merge 10 commits intodevelopfrom
feat/editor-delegation-token

Conversation

@skelz0r
Copy link
Copy Markdown
Member

@skelz0r skelz0r commented Apr 15, 2026

No description provided.

skelz0r added 5 commits April 14, 2026 16:06
Allow editors to authenticate via JWT tokens and access API endpoints
on behalf of authorization requests they have active delegations for.

The delegation is verified per-request by matching the editor's
delegations against the recipient SIRET parameter. Revoked delegations
are excluded.

Regular (non-editor) tokens are unaffected.
An editor can have multiple active delegations for the same recipient
SIRET (from different DataPass requests). Without disambiguation, the
API cannot determine which delegation to use.

Add a `delegation_id` query parameter that matches `EditorDelegation#id`
(a non-inferable UUID) to uniquely identify the delegation. When
multiple delegations exist for the same SIRET and no `delegation_id`
is provided, return a 422 with error code 00212.

Using the EditorDelegation UUID instead of the DataPass external id
avoids leaking an enumerable identifier in the URL.
…I access

Editors managing multiple client administrations need a single token
that, combined with a recipient SIRET, grants access to delegated
authorization data — instead of one token per authorization request.

- Create editor_tokens table (migration + FK validation)
- EditorToken model: JWT with `editor: true` flag, rehash/active/expired/blacklisted
- Admin: create tokens from editor edit page, list with status badges
- Editor: display tokens table with copy-to-clipboard on delegations page
- Routes, i18n, factory, and specs
Both models duplicated validation, scopes (active, unexpired,
not_blacklisted) and instance methods (rehash, expired?, blacklisted?,
active?). Each model now only defines its own jwt_data payload.
Explain how editor JWTs resolve to a specific authorization request via
the recipient SIRET, why delegation_id (non-inferable UUID) is required
to disambiguate multiple delegations on the same SIRET, and how
revocation and legacy tokens behave.
@skelz0r skelz0r self-assigned this Apr 15, 2026
skelz0r added 5 commits April 15, 2026 13:19
Historically Rack::Attack discriminated counters by SHA256 of the JWT.
With editor tokens, a single JWT can legitimately target many
authorization requests via delegations, so per-signature counting gives
either an overly generous or underly generous budget depending on the
shape of the traffic.

Move the discriminator to the authorization request id so that:
- classical and editor tokens pointing to the same habilitation share
  the counter (consistent "per habilitation" semantics);
- each delegation of an editor token gets its own counter;
- rate_limit_per_minute is sourced from the resolved habilitation
  rather than from a value baked into the JWT.

For classical tokens the id is embedded in the cached JwtUser payload;
for editor tokens it is resolved per-request via EditorDelegation using
recipient (+ optional delegation_id), memoized in req.env to avoid
redundant SQL across throttle rules. When resolution fails (missing or
unknown recipient), fall back to "editor:<id>" / "token:<id>" so that
global throttles still apply.

Public documentation is updated accordingly: wording switches from "par
jeton" to "par habilitation", with an explicit note that multiple
tokens on the same habilitation share the counter.
The API side of the apistration now reads editor_token.scopes during
authentication (JwtTokenService#enhanced_jwt_data_for_editor_token), but
the site-side migration and admin creation path never persisted any
scope. Tokens issued from the site would therefore be unusable on
protected endpoints as soon as the database schema tracks what the
migrations actually create.

Add the missing jsonb column (with a gin index mirroring tokens), ship
it in the JWT payload, and default newly minted editor tokens to the
union of scopes granted by the editor's active delegations so they can
exercise every habilitation they are delegated for. Runtime
authorization remains the source of truth and will narrow this set per
delegated authorization request in a follow-up commit.
When an editor token hit a v3+ endpoint with a missing or malformed
recipient, the HandleEditorDelegation callback ran before
verify_recipient_is_a_siret! and raised NotAuthorizedError because no
delegation could be resolved for a non-SIRET value. Bad input was
therefore surfaced as a 403 only for editor tokens, diverging from the
422 Invalid Recipient response every other caller gets.

Move the include below the recipient validation so that the callback
chain halts with the normal 422 before the delegation lookup runs.
HandleEditorDelegation only asserted that a matching EditorDelegation
existed; it never projected the chosen authorization request back onto
current_user. HandleTokens#authorize_access_to_resource! therefore
evaluated scopes against the editor token's own, possibly broader,
scope set, and RateLimitingService#ip_forbidden_access? / custom rate
limits kept reading the empty values baked into the editor JWT. The
effect was that calls outside the delegated DataPass scopes were
accepted, and any IP whitelist or per-habilitation rate limit declared
on the delegated authorization request was silently skipped.

Swap current_user for a JwtUser rebuilt from the delegation's
authorization request (scopes, allowed_ips, rate_limit_per_minute,
authorization_request_id) and re-run authorize_access_to_resource!
after the delegation callback so the scope check sees the narrowed
values. For classical tokens the chain is effectively unchanged beyond
a minor reordering of callbacks.
The AR-based discriminator silently returned nil whenever the bearer
token was not a decodable JWT, which is exactly the FranceConnect flow
(opaque access tokens handed straight to the IdP) and the legacy
HTTP_X_API_KEY flow. Rack::Attack therefore skipped the throttle, and
the RateLimitHeadersMiddleware had nothing to hang RateLimit-Limit /
RateLimit-Remaining / RateLimit-Reset on.

Fall back to the previous SHA256-of-the-raw-token discriminator when
JwtTokenService cannot resolve a user, so these flows keep a stable
per-caller bucket and our rswag contracts keep seeing the rate limit
headers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant