fraiseql_server/service_account.rs
1//! Service-account authentication (ADR-0018).
2//!
3//! A **service account** grants an external daemon a named, auditable, ceiling-bounded
4//! identity: a `[service_accounts.<name>]` block declaring an **env-indirected** static
5//! secret and a `run_as` ceiling (`roles`/`scopes`/`tenant`). It extends the static
6//! API-key seam ([`crate::api_key`]) — same header, same SHA-256 + constant-time
7//! compare — but carries a full ceiling minted through
8//! [`SecurityContext::service_account`] (`ActorType::ServiceAccount`,
9//! `user_id = service_account:<name>`), rather than scopes only.
10//!
11//! # Credential presentation (ADR-0018, amended 2026-07-16)
12//!
13//! The secret is presented on the **`x-api-key`** header (optionally `ApiKey <secret>` /
14//! `Bearer <secret>` in the value), NOT on `Authorization: Bearer`. The original ADR said
15//! `Authorization: Bearer`, but that header is consumed by the JWT auth middleware before
16//! any api-key seam runs, so a bearer secret would be 401'd as an invalid JWT whenever
17//! OIDC/HS256 is configured. Reusing the api-key header keeps this on the existing seam
18//! with **no change to the security-critical auth middleware** (ADR decision 3's intent).
19//!
20//! # Fail-closed
21//!
22//! - Unknown account / bad secret are **indistinguishable** — a present-but-unmatched secret yields
23//! no context (the caller 401s), with no account-existence oracle.
24//! - An account with an empty ceiling authenticates but has **no authority** (RLS / field-authz
25//! deny its writes).
26//! - The secret plaintext is read once at startup and discarded — only its SHA-256 hash lives in
27//! process memory; the compiled schema/config holds only the env-var *name*.
28
29use std::{collections::HashMap, sync::Arc};
30
31use axum::http::{HeaderMap, HeaderName};
32use fraiseql_core::security::{ENRICHED_NAMESPACE_PREFIX, SecurityContext};
33use serde::Deserialize;
34use subtle::ConstantTimeEq;
35use tracing::{debug, warn};
36
37use crate::api_key::sha256_hash;
38
39/// The header a service account presents its secret on (shared with static API keys).
40const SA_HEADER: &str = "x-api-key";
41
42/// A `[service_accounts.<name>]` config block. Holds only **non-secret** material — the
43/// secret plaintext lives in the environment variable named by `secret_env`.
44#[derive(Debug, Clone, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct ServiceAccountConfig {
47 /// Name of the environment variable holding the plaintext bearer secret. The secret
48 /// is **never** inlined; the config holds only this name.
49 pub secret_env: String,
50 /// The `run_as` ceiling — roles granted. Empty ⇒ no role authority.
51 #[serde(default)]
52 pub roles: Vec<String>,
53 /// The `run_as` ceiling — scopes granted. Empty ⇒ no scope authority.
54 #[serde(default)]
55 pub scopes: Vec<String>,
56 /// Optional tenant pin. Omitted ⇒ global / NULL tenant.
57 #[serde(default)]
58 pub tenant: Option<String>,
59 /// Optional server-injected `fraiseql.enriched.*` fields, the **only** sanctioned
60 /// deviation from uniform enrichment (ADR-0016 decision 6 / ADR-0018 decision 5) —
61 /// for a daemon with no natural actor row. Server-injected, never token-asserted.
62 #[serde(default)]
63 pub static_enriched: HashMap<String, serde_json::Value>,
64}
65
66/// The outcome of [`ServiceAccountAuthenticator::resolve`] — the shared decision every
67/// entry point maps onto its own 401/response shape.
68#[derive(Debug)]
69#[non_exhaustive]
70pub enum SaAuth {
71 /// No secret header present — proceed with the existing (JWT / anonymous) context.
72 NoSecret,
73 /// A JWT principal **and** a secret header on one request — ambiguous identity;
74 /// the caller must reject (fail-closed, no silent precedence).
75 Ambiguous,
76 /// The secret matched a service account — use this context.
77 Authenticated(Box<SecurityContext>),
78 /// A secret header is present but matched no service account. The caller may try
79 /// another secret authenticator (a static API key), else 401 — a bad secret is
80 /// **indistinguishable** from an unknown account (no oracle).
81 Unmatched,
82}
83
84/// A service account with its secret resolved to a hash (plaintext discarded).
85#[derive(Debug, Clone)]
86struct ResolvedServiceAccount {
87 name: String,
88 secret_hash: [u8; 32],
89 roles: Vec<String>,
90 scopes: Vec<String>,
91 tenant: Option<String>,
92 static_enriched: HashMap<String, serde_json::Value>,
93}
94
95/// Authenticates service-account secrets presented on the api-key header.
96pub struct ServiceAccountAuthenticator {
97 header_name: HeaderName,
98 accounts: Vec<ResolvedServiceAccount>,
99}
100
101impl std::fmt::Debug for ServiceAccountAuthenticator {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 f.debug_struct("ServiceAccountAuthenticator")
104 .field("header_name", &self.header_name)
105 .field("accounts_count", &self.accounts.len())
106 .finish()
107 }
108}
109
110impl ServiceAccountAuthenticator {
111 /// Build an authenticator from the `[service_accounts]` config, resolving each
112 /// account's secret via `resolve_secret` (production: `|env| std::env::var(env).ok()`).
113 ///
114 /// An account whose `secret_env` is unset/empty is **skipped with a warning** — it is
115 /// simply unusable (fail-closed), never a silent anonymous grant. Returns `None` when
116 /// no account resolves.
117 #[must_use]
118 pub fn from_config(
119 accounts: &HashMap<String, ServiceAccountConfig>,
120 resolve_secret: impl Fn(&str) -> Option<String>,
121 ) -> Option<Arc<Self>> {
122 let mut resolved = Vec::new();
123 for (name, cfg) in accounts {
124 match resolve_secret(&cfg.secret_env) {
125 Some(secret) if !secret.is_empty() => {
126 resolved.push(ResolvedServiceAccount {
127 name: name.clone(),
128 secret_hash: sha256_hash(secret.as_bytes()),
129 roles: cfg.roles.clone(),
130 scopes: cfg.scopes.clone(),
131 tenant: cfg.tenant.clone(),
132 static_enriched: cfg.static_enriched.clone(),
133 });
134 },
135 _ => warn!(
136 account = %name,
137 secret_env = %cfg.secret_env,
138 "service account skipped — its secret env var is unset or empty"
139 ),
140 }
141 }
142 if resolved.is_empty() {
143 return None;
144 }
145 let header_name = HeaderName::from_static(SA_HEADER);
146 Some(Arc::new(Self {
147 header_name,
148 accounts: resolved,
149 }))
150 }
151
152 /// Resolve a service-account principal from `headers`, honoring the JWT-collision
153 /// rule (ADR-0018 amendment / rider 2). `jwt_present` is whether the request already
154 /// carries a JWT-derived principal. The same logic backs every entry point (GraphQL,
155 /// `/ws`, REST) so they cannot drift.
156 #[must_use]
157 pub fn resolve(&self, headers: &HeaderMap, jwt_present: bool) -> SaAuth {
158 if !self.header_present(headers) {
159 return SaAuth::NoSecret;
160 }
161 if jwt_present {
162 // A JWT principal AND a secret header on one request is an ambiguous
163 // identity — reject fail-closed rather than silently pick one.
164 return SaAuth::Ambiguous;
165 }
166 self.authenticate(headers).map_or(SaAuth::Unmatched, SaAuth::Authenticated)
167 }
168
169 /// Whether the request carries a (non-empty) service-account secret header. Used by
170 /// callers to reject a request that also carries a JWT (ambiguous identity).
171 #[must_use]
172 pub fn header_present(&self, headers: &HeaderMap) -> bool {
173 headers
174 .get(&self.header_name)
175 .and_then(|v| v.to_str().ok())
176 .is_some_and(|s| !s.is_empty())
177 }
178
179 /// Authenticate a request. Returns the service account's [`SecurityContext`] on an
180 /// exact constant-time secret match, or `None` when the header is absent **or**
181 /// present-but-unmatched (the caller cannot distinguish the two — no oracle).
182 #[must_use]
183 pub fn authenticate(&self, headers: &HeaderMap) -> Option<Box<SecurityContext>> {
184 let raw = headers.get(&self.header_name)?.to_str().ok()?;
185 if raw.is_empty() {
186 return None;
187 }
188 // Strip an optional `ApiKey ` / `Bearer ` prefix on the value (ASCII, byte-safe).
189 let secret = strip_scheme_prefix(raw);
190 let presented = sha256_hash(secret.as_bytes());
191
192 for account in &self.accounts {
193 if bool::from(presented.ct_eq(&account.secret_hash)) {
194 debug!(account = %account.name, "service account authenticated");
195 return Some(Box::new(build_context(account)));
196 }
197 }
198 warn!("service-account authentication failed: no matching account");
199 None
200 }
201}
202
203/// Strip a leading `ApiKey ` / `Bearer ` scheme (case-insensitive) from a header value.
204/// The prefix is ASCII, so the byte slice that follows is on a char boundary.
205fn strip_scheme_prefix(raw: &str) -> &str {
206 let bytes = raw.as_bytes();
207 if bytes.len() > 7
208 && (bytes[..7].eq_ignore_ascii_case(b"apikey ")
209 || bytes[..7].eq_ignore_ascii_case(b"bearer "))
210 {
211 &raw[7..]
212 } else {
213 raw
214 }
215}
216
217/// Mint the service account's [`SecurityContext`] from its ceiling, injecting any
218/// `static_enriched` fields under the forge-proof `fraiseql.enriched.*` namespace.
219fn build_context(account: &ResolvedServiceAccount) -> SecurityContext {
220 let tenant = account.tenant.as_ref().map(fraiseql_core::types::TenantId::new);
221 let request_id = format!("sa-{}", uuid::Uuid::new_v4());
222 let mut ctx = SecurityContext::service_account(
223 account.name.clone(),
224 request_id,
225 account.roles.clone(),
226 account.scopes.clone(),
227 tenant,
228 );
229 for (field, value) in &account.static_enriched {
230 ctx.attributes
231 .insert(format!("{ENRICHED_NAMESPACE_PREFIX}{field}"), value.clone());
232 }
233 ctx
234}
235
236/// Build a [`ServiceAccountAuthenticator`] from the compiled schema's
237/// `security.service_accounts` block, resolving secrets from the process environment.
238#[must_use]
239pub fn service_account_authenticator_from_schema(
240 schema: &fraiseql_core::schema::CompiledSchema,
241) -> Option<Arc<ServiceAccountAuthenticator>> {
242 let security = schema.security.as_ref()?;
243 let value = security.additional.get("service_accounts")?;
244 let accounts: HashMap<String, ServiceAccountConfig> = serde_json::from_value(value.clone())
245 .map_err(|e| warn!(error = %e, "Failed to parse security.service_accounts config"))
246 .ok()?;
247 ServiceAccountAuthenticator::from_config(&accounts, |env| std::env::var(env).ok())
248}
249
250#[cfg(test)]
251mod tests;