Service-to-Service JWT Delegation
Table of Contents
Context
Service accounts authenticate to NATS using their own RS256 JWT
(Authorization: Bearer <service-jwt>), established by the service_nats_client
pattern from the service-to-service auth design. When a backend service calls
another on behalf of an end-user, the receiving service sees the calling
service's JWT. The receiving service therefore builds a DB context scoped to
the calling service's tenant and party — not the original user's — resulting in
wrong RLS context, wrong audit attribution, and incorrect modified_by values.
The current workaround on feature/service-rbac-step2f passes user identity as
ad-hoc payload fields (on_behalf_of, tenant_id, actor) on individual
message structs. This requires manually updating every message type and every
handler that participates in delegation, and provides no cryptographic guarantee
on the delegated identity.
Guiding Principles
- Always forward, never decide. A handler that makes outbound service calls unconditionally forwards the inbound JWT as delegation. It does not inspect whether delegation is "needed" for a particular call. The receiving end handles both delegated and non-delegated calls identically.
- JWT integrity. The delegated identity is a cryptographically signed JWT, not a plain payload field. The receiving service validates it independently. A corrupt or expired delegated token is rejected.
- No message struct changes. Delegation context travels in NATS message headers, not in payload fields. Message structs carry only business data.
- No per-handler special casing.
make_request_contextis updated once. All handlers — existing and future — benefit automatically. - Constants, not strings. All header names are defined as
constexprstring view constants. No magic strings anywhere.
Architecture
Qt / caller Reporting Service Scheduler Service
──────────── ───────────────── ─────────────────
Authorization: Bearer Authorization: Bearer Authorization: Bearer
<user-jwt> ──► <service-jwt> ──► <service-jwt>
X-Delegated-Authorization:
Bearer <user-jwt>
make_request_context: make_request_context: make_request_context:
Authorization JWT ──► Authorization JWT ──► X-Delegated-Auth JWT
→ user context → service context → user context ✓
The calling service extracts the inbound Authorization header and re-sends it
as X-Delegated-Authorization on every outbound call. The receiving service's
make_request_context checks X-Delegated-Authorization first; if present and
valid, it builds the DB context from the user's claims (tenant, party,
visible_party_ids, actor, roles). performed_by is always the receiving
service's own service account — stamped by the handler, not taken from any JWT.
For system-initiated calls (e.g. startup reconciliation) there is no inbound
user JWT. extract_bearer returns an empty string. with_delegation("") is a
no-op. The outbound call carries only the service JWT. make_request_context
falls through to Authorization and returns the service context. No special
casing in any handler.
What Needs to Be Built
Step 1 — Header name constants
New file: projects/ores.nats/include/ores.nats/domain/headers.hpp
namespace ores::nats::headers { inline constexpr std::string_view authorization = "Authorization"; inline constexpr std::string_view delegated_authorization = "X-Delegated-Authorization"; inline constexpr std::string_view bearer_prefix = "Bearer "; inline constexpr std::string_view x_error = "X-Error"; }
Replace all string literals for these values throughout the codebase with the
constants. Affected files include request_context.cpp, nats_client.cpp, and
all handlers that currently hard-code "Authorization" or "X-Error".
Step 2 — nats_client::with_delegation
Extend nats_client in ores.nats/include/ores.nats/service/nats_client.hpp:
- Add private field
std::string delegation_token_(empty by default). - Add public factory method:
/// Returns a new nats_client that shares this instance's underlying /// connection and token provider, but injects /// X-Delegated-Authorization: Bearer <token> /// on every authenticated_request call. /// If token is empty, returns an equivalent of *this (no delegation). [[nodiscard]] nats_client with_delegation(std::string token) const;
- In
do_authenticated_request: ifdelegation_token_is non-empty, mergeheaders::delegated_authorizationinto the outgoing headers map before callingrequest_sync.
The returned nats_client is a value type sharing the underlying client* /
token_provider. It is stack-allocated per request in the calling handler.
Thread safety is preserved: no mutable shared state is modified.
Add a free helper (same header or ores.nats/service/nats_helpers.hpp):
/// Extracts the raw token from "Authorization: Bearer <token>" in msg.headers. /// Returns empty string if the header is absent or malformed. std::string extract_bearer(const ores::nats::message& msg);
Step 3 — make_request_context updated once
In projects/ores.service/src/service/request_context.cpp, update
make_request_context to check headers::delegated_authorization first:
Priority:
1. headers::delegated_authorization present
→ make_context_from_jwt(base_ctx, token, verifier)
→ expired : return token_expired (hard reject)
→ invalid : return unauthorized
→ valid : return full user context
2. headers::authorization present
→ existing behaviour (unchanged)
3. Neither present
→ return unauthorized
No other changes to make_request_context. All handlers — at the scheduler,
IAM, and any future service — benefit without modification.
Step 4 — Calling handlers: always-forward pattern
In every handler method that constructs a service object which makes outbound NATS calls, replace:
// Before some_service svc(ctx_, svc_nats_); // After some_service svc(ctx_, svc_nats_.with_delegation(extract_bearer(msg)));
This is the entire change at the handler level. The same line is used
unconditionally regardless of whether the inbound request was user-initiated or
system-initiated. extract_bearer returns empty on system calls; with_delegation("")
is a no-op.
Initial affected handlers:
report_definition_handler::schedule()report_definition_handler::unschedule()
Step 5 — Scheduler handler: remove resolve_context
make_request_context now returns a fully-populated context (tenant, party,
actor) for both delegated user calls and direct service calls. All accounts —
user and service — always have a party (service accounts use system parties).
There is no null case.
Remove resolve_context from job_definition_handler entirely. Replace the
three write handlers (schedule, schedule_batch, unschedule) with the
standard pattern:
auto ctx_expected = make_request_context(ctx_, msg, verifier_); if (!ctx_expected) { error_reply(...); return; } const auto& ctx = *ctx_expected; // ctx is always fully populated. req->definition.performed_by = ctx.service_account(); service::job_definition_service svc(ctx); svc.save_definition(req->definition);
Step 6 — Protocol cleanup
Remove all ad-hoc delegation fields added during earlier iterations:
on_behalf_offromschedule_job_request,schedule_jobs_batch_request,unschedule_job_request(already removed).tenant_id,party_id,actorfromunschedule_job_request(revert).
Message structs carry only business payload.
Step 7 — report_scheduling_service cleanup
Remove user_jwt parameter from all methods. The service calls
nats_.authenticated_request(...) exactly as before; the nats_client it
received via the always-forward pattern handles delegation transparently.
Invariants
| Scenario | X-Delegated-Authorization | Authorization | Context at receiver |
|---|---|---|---|
| Qt → Service | absent | user JWT | user context |
| Service A → Service B (user req) | user JWT | service JWT | user context |
| Service A → Service B (system) | absent | service JWT | service context |
| Multi-hop A → B → C (user req) | user JWT (forwarded) | service JWT | user context |
performed_by is always the receiving service's service_account(), stamped
by the handler after make_request_context returns. It is never taken from
any JWT.
Affected Files
Modified files
projects/ores.nats/include/ores.nats/service/nats_client.hpp— addwith_delegation,delegation_token_fieldprojects/ores.nats/src/service/nats_client.cpp— implementwith_delegation, inject header indo_authenticated_request; addextract_bearerprojects/ores.service/src/service/request_context.cpp— checkX-Delegated-AuthorizationbeforeAuthorizationprojects/ores.reporting.core/include/ores.reporting.core/messaging/report_definition_handler.hpp— always-forward pattern inschedule/unscheduleprojects/ores.reporting.core/include/ores.reporting.core/service/report_scheduling_service.hpp— removeuser_jwtfrom all method signaturesprojects/ores.reporting.core/src/service/report_scheduling_service.cpp— removeuser_jwtfrom all methods and NATS requestsprojects/ores.scheduler.core/include/ores.scheduler.core/messaging/job_definition_handler.hpp— removeresolve_context; standard context pattern in write handlersprojects/ores.scheduler.api/include/ores.scheduler.api/messaging/scheduler_protocol.hpp— revertunschedule_job_requestto business payload only
Rollout Order
- Header constants — replace all string literals.
nats_client— addwith_delegationandextract_bearer.make_request_context— check delegated header first.- Scheduler handler — remove
resolve_context. - Reporting handler and service — always-forward pattern, remove
user_jwt. - Protocol cleanup — revert
unschedule_job_request. - Build and test end-to-end: UI schedule → reporting → scheduler → DB with correct tenant/party/actor.