Skip to main content

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;