Skip to main content

fraiseql_core/schema/
security_config.rs

1//! Security configuration types for compiled schemas.
2//!
3//! Contains role definitions, scope types, and injected parameter sources
4//! that are compiled from `fraiseql.toml` into `schema.compiled.json`.
5
6use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9
10use super::domain_types::{RoleName, Scope};
11
12#[cfg(test)]
13mod tests;
14
15/// Source from which an injected SQL parameter is resolved at runtime.
16///
17/// Injected parameters are not exposed in the GraphQL schema. They are
18/// silently added to SQL queries and function calls, resolved from the
19/// authenticated request context.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "source", content = "claim", rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum InjectedParamSource {
24    /// Extract a value from the JWT claims, through
25    /// [`SecurityContext::jwt_claim`](crate::security::SecurityContext::jwt_claim): the
26    /// claim as the token carried it. A claim the token does not carry is an error;
27    /// no other claim and no derived value stands in for it (#1388).
28    Jwt(String),
29    /// A JWT claim the token may lack (#1418): `NULL` when it is absent, so one
30    /// mutation serves a direct token and a delegated one (an RFC 8693 `act` claim).
31    ///
32    /// Valid only as a **mutation function argument**. On a query, or a type's
33    /// `inject_params`, an inject param is a row filter, and an absent claim must not
34    /// become one: the compiler refuses it there and the runtime refuses it too. The
35    /// tenant claim is never optional.
36    JwtOptional(String),
37    /// Extract a DB-resolved enriched-identity field, read from the reserved
38    /// `fraiseql.enriched.*` attribute namespace (#539).
39    ///
40    /// Unlike [`InjectedParamSource::Jwt`], there is **no** fallback to a raw
41    /// claim or a well-known field: the namespace is forge-proof and a missing
42    /// enriched field is a hard error, so a raw claim of the same name can never
43    /// impersonate a DB-derived identity parameter. The wrapped value is the
44    /// enriched field name, without the `fraiseql.enriched.` prefix.
45    Enrichment(String),
46}
47
48/// Role definition for field-level RBAC.
49///
50/// Defines which GraphQL scopes a role grants access to.
51/// Used by the runtime to determine which fields a user can access
52/// based on their assigned roles.
53///
54/// # Example
55///
56/// ```json
57/// {
58///   "name": "admin",
59///   "description": "Administrator with all scopes",
60///   "scopes": ["admin:*"]
61/// }
62/// ```
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct RoleDefinition {
65    /// Role name (e.g., "admin", "user", "viewer").
66    pub name: RoleName,
67
68    /// Optional role description for documentation.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub description: Option<String>,
71
72    /// List of scopes this role grants access to.
73    /// Scopes follow the format: `action:resource` (e.g., "read:User.email", "admin:*")
74    pub scopes: Vec<Scope>,
75}
76
77impl RoleDefinition {
78    /// Create a new role definition.
79    #[must_use]
80    pub fn new(name: impl Into<String>, scopes: Vec<String>) -> Self {
81        Self {
82            name:        RoleName::new(name),
83            description: None,
84            scopes:      scopes.into_iter().map(Scope::new).collect(),
85        }
86    }
87
88    /// Add a description to the role.
89    #[must_use]
90    pub fn with_description(mut self, description: String) -> Self {
91        self.description = Some(description);
92        self
93    }
94
95    /// Check if this role has a specific scope.
96    ///
97    /// Supports exact matching and wildcard patterns:
98    /// - `read:User.email` matches exactly
99    /// - `read:*` matches any scope starting with "read:"
100    /// - `read:User.*` matches "read:User.email", "read:User.name", etc.
101    /// - `admin:*` matches any admin scope
102    #[must_use]
103    pub fn has_scope(&self, required_scope: &str) -> bool {
104        self.scopes.iter().any(|scope| {
105            let scope = scope.as_str();
106            if scope == "*" {
107                return true; // Wildcard matches everything
108            }
109
110            if scope == required_scope {
111                return true; // Exact match
112            }
113
114            // Handle wildcard patterns like "read:*" or "admin:*". The delimiter is
115            // part of the match: `read:*` grants `read:…` only, never `readwrite:…`
116            // scopes that merely share the string prefix (#784).
117            if let Some(prefix) = scope.strip_suffix(":*") {
118                return required_scope
119                    .strip_prefix(prefix)
120                    .is_some_and(|rest| rest.starts_with(':'));
121            }
122
123            // Handle Type.* wildcard patterns like "read:User.*". Only a prefix
124            // ending on a `.`/`:` boundary is a wildcard grant; a bare prefix like
125            // "read*" would otherwise also match "readwrite:…" (#784).
126            if let Some(prefix) = scope.strip_suffix('*') {
127                return (prefix.ends_with('.') || prefix.ends_with(':'))
128                    && required_scope.starts_with(prefix);
129            }
130
131            false
132        })
133    }
134}
135
136/// Tenancy isolation mode for multi-tenant deployments.
137///
138/// Determines how tenant data is separated at the database level.
139/// Configured via `[fraiseql.tenancy]` in `fraiseql.toml`.
140#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142#[non_exhaustive]
143pub enum TenancyMode {
144    /// Single-tenant deployment, no isolation machinery.
145    #[default]
146    None,
147
148    /// Row-level isolation: shared tables with `@tenant_id` column injection.
149    ///
150    /// The compiler validates that all queries/mutations referencing
151    /// `@tenant_id`-annotated types have `inject_params` wired correctly.
152    /// At runtime, `InjectedParamSource::Jwt` resolves the tenant claim
153    /// and injects a WHERE clause.
154    Row,
155
156    /// Schema-level isolation: per-tenant PostgreSQL schemas.
157    ///
158    /// Each tenant's connection pool is *established* with
159    /// `search_path = tenant_{key},public` (lowered into the PostgreSQL startup
160    /// `options` parameter), so every connection the pool opens carries it —
161    /// including ones opened later to grow the pool and replacements created
162    /// after a backend disconnects. `TenantExecutorFactory` provisions the
163    /// schema via DDL at registration and verifies the isolation took before
164    /// returning an executor.
165    ///
166    /// It is deliberately **not** a `SET search_path` statement: that is
167    /// session-scoped, so it configures one pooled connection and leaves the
168    /// rest resolving against `public` (#809).
169    Schema,
170}
171
172impl std::fmt::Display for TenancyMode {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            Self::None => write!(f, "none"),
176            Self::Row => write!(f, "row"),
177            Self::Schema => write!(f, "schema"),
178        }
179    }
180}
181
182/// Tenancy configuration for multi-tenant deployments.
183///
184/// Compiled from `[fraiseql.tenancy]` in `fraiseql.toml` into the
185/// `security.tenancy` section of `schema.compiled.json`.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct TenancyConfig {
188    /// Isolation strategy: `"none"`, `"row"`, or `"schema"`.
189    #[serde(default)]
190    pub mode: TenancyMode,
191
192    /// JWT claim name that carries the tenant identifier.
193    ///
194    /// Defaults to `"tenant_id"`. The compiler writes it into row-mode
195    /// `jwt:<tenant_claim>` injects, and the server derives
196    /// `SecurityContext::tenant_id` — the key per-tenant dispatch, the default RLS
197    /// policy and the caches scope by — from this claim of each validated token
198    /// (#1388). Read once at boot: `security` is boot-frozen.
199    #[serde(default = "default_tenant_claim")]
200    pub tenant_claim: String,
201}
202
203/// The JWT claim that names a request's tenant when `[fraiseql.tenancy]` does not
204/// say otherwise.
205pub const DEFAULT_TENANT_CLAIM: &str = "tenant_id";
206
207fn default_tenant_claim() -> String {
208    DEFAULT_TENANT_CLAIM.to_string()
209}
210
211impl Default for TenancyConfig {
212    fn default() -> Self {
213        Self {
214            mode:         TenancyMode::None,
215            tenant_claim: default_tenant_claim(),
216        }
217    }
218}
219
220/// Returns `true` when tenancy config equals the default (mode=none, `claim=tenant_id`).
221///
222/// Used by serde to skip serializing the tenancy field when it carries no information.
223fn is_default_tenancy(t: &TenancyConfig) -> bool {
224    *t == TenancyConfig::default()
225}
226
227/// Declaration that this deployment relies on database Row-Level Security.
228///
229/// Compiled from `[security.rls]` in `fraiseql.toml`. FraiseQL does not author RLS
230/// policies — they live in the database, keyed on the session variables the runtime
231/// sets from the request identity — so the compiled schema can only carry the
232/// operator's *declaration* that they exist. The server turns that declaration into
233/// a checkable claim: when a multi-tenant schema declares RLS, boot verifies against
234/// the live catalog that policies really are in force, and refuses to start if they
235/// are not (#762).
236///
237/// Before this section existed, [`CompiledSchema::has_rls_configured`] counted
238/// `security.additional["policies"]` — a key #612 made a hard compile error — so it
239/// answered `false` for every schema any supported workflow could produce, and the
240/// gates that depend on it were inert (#758).
241///
242/// [`CompiledSchema::has_rls_configured`]: crate::schema::CompiledSchema::has_rls_configured
243// Deliberately not `Copy`: the serde `skip_serializing_if` predicate must take a
244// reference, and a config struct that gains a second field would lose `Copy` anyway.
245#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(default, deny_unknown_fields)]
247pub struct RlsConfig {
248    /// Whether database Row-Level Security is relied upon for data isolation.
249    pub enabled: bool,
250}
251
252/// Returns `true` when the RLS declaration carries no information.
253fn is_default_rls(r: &RlsConfig) -> bool {
254    *r == RlsConfig::default()
255}
256
257/// Security configuration from fraiseql.toml.
258///
259/// Contains role definitions and other security-related settings
260/// that are compiled into schema.compiled.json.
261///
262/// `deny_unknown_fields`: this object is the seam seven security subsystems are
263/// configured through, and it used to carry a `#[serde(flatten)]` catch-all that
264/// seven string lookups read — so `rate_limitting` or `token_revokation` in a
265/// compiled schema landed in the catch-all, the lookup missed, and the subsystem
266/// came up silently unconfigured (#977). A typo'd key must fail the load, not
267/// disable the subsystem it names (the compiled-schema seam rule).
268#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(deny_unknown_fields)]
270pub struct SecurityConfig {
271    /// Role definitions mapping role names to their granted scopes.
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub role_definitions: Vec<RoleDefinition>,
274
275    /// Default role when none is specified.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub default_role: Option<String>,
278
279    /// Whether this schema serves multiple tenants with data isolation via RLS.
280    ///
281    /// Declared as `[security] multi_tenant = true`. A non-`none`
282    /// [`tenancy.mode`](TenancyConfig::mode) implies it, so this flag is only needed
283    /// by deployments that separate tenants without FraiseQL's own tenancy
284    /// machinery — read [`CompiledSchema::is_multi_tenant`], never this field, when
285    /// asking whether a deployment is multi-tenant.
286    ///
287    /// [`CompiledSchema::is_multi_tenant`]: crate::schema::CompiledSchema::is_multi_tenant
288    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
289    pub multi_tenant: bool,
290
291    /// Declaration that database Row-Level Security isolates this schema's data.
292    ///
293    /// Compiled from `[security.rls]`. Consumed by the boot gate: a multi-tenant
294    /// schema with caching enabled and no RLS declaration refuses to start, and one
295    /// that *does* declare RLS has the declaration verified against the live
296    /// database catalog.
297    #[serde(default, skip_serializing_if = "is_default_rls")]
298    pub rls: RlsConfig,
299
300    /// Tenancy isolation configuration for multi-tenant deployments.
301    ///
302    /// When present and `mode != "none"`, the runtime enforces tenant isolation
303    /// at the database level (row-based or schema-based).
304    #[serde(default, skip_serializing_if = "is_default_tenancy")]
305    pub tenancy: TenancyConfig,
306
307    /// Operation cost budgets (#379), compiled from `[security.cost_budget]`.
308    ///
309    /// `per_request_max` is enforced by the **executor** (every transport that
310    /// executes a GraphQL document), not only by the HTTP handler; the
311    /// per-tenant default seeds the tenant registry's rolling windows.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub cost_budget: Option<CostBudgetConfig>,
314
315    /// Default authorization policy applied when an operation names none.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub default_policy: Option<String>,
318
319    /// Custom authorization rules, compiled from `[[security.rules]]`.
320    #[serde(default, skip_serializing_if = "Vec::is_empty")]
321    pub rules: Vec<super::config_types::AuthorizationRule>,
322
323    /// Authorization policies (RBAC/ABAC), compiled from `[[security.policies]]`.
324    #[serde(default, skip_serializing_if = "Vec::is_empty")]
325    pub policies: Vec<super::config_types::AuthorizationPolicy>,
326
327    /// Field-level authorization rules, compiled from `[[security.field_auth]]`.
328    #[serde(default, skip_serializing_if = "Vec::is_empty")]
329    pub field_auth: Vec<super::config_types::FieldAuthRule>,
330
331    /// Enterprise security flags, compiled from `[security.enterprise]`.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub enterprise: Option<super::config_types::EnterpriseSecurityConfig>,
334
335    /// Error sanitization, compiled from `[security.error_sanitization]`.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub error_sanitization: Option<ErrorSanitizationConfig>,
338
339    /// Rate limiting, compiled from `[security.rate_limiting]`.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub rate_limiting: Option<RateLimitingSecurityConfig>,
342
343    /// OAuth state / PKCE blob encryption, compiled from
344    /// `[security.state_encryption]`.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub state_encryption: Option<StateEncryptionConfig>,
347
348    /// PKCE for OAuth Authorization Code flows, compiled from `[security.pkce]`.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub pkce: Option<PkceSecurityConfig>,
351
352    /// API key authentication, compiled from `[security.api_keys]`.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub api_keys: Option<ApiKeySecurityConfig>,
355
356    /// Token revocation, compiled from `[security.token_revocation]`.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub token_revocation: Option<TokenRevocationSecurityConfig>,
359
360    /// Trusted documents / persisted-query allowlist, compiled from
361    /// `[security.trusted_documents]`.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub trusted_documents: Option<TrustedDocumentsConfig>,
364
365    /// Force persisted-operations-only enforcement (#379).
366    ///
367    /// When `true`, the runtime forces the trusted-document store into `strict`
368    /// mode regardless of `trusted_documents.mode`. Requires a configured
369    /// trusted-documents manifest to have any effect.
370    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
371    pub persisted_queries_only: bool,
372
373    /// Machine service accounts, keyed by account name (`security.service_accounts`).
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub service_accounts: Option<HashMap<String, ServiceAccountConfig>>,
376}
377
378/// Controls how much error detail is exposed to API clients.
379///
380/// When enabled, internal error messages, SQL, and stack traces are stripped.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(default, deny_unknown_fields)]
383pub struct ErrorSanitizationConfig {
384    /// Enable error sanitization (default: false — opt-in).
385    pub enabled:                     bool,
386    /// Strip stack traces, SQL fragments, file paths (default: true).
387    pub hide_implementation_details: bool,
388    /// Replace raw database error messages with a generic message (default: true).
389    pub sanitize_database_errors:    bool,
390    /// Replacement message shown to clients when an internal error is sanitized.
391    pub custom_error_message:        Option<String>,
392}
393
394impl Default for ErrorSanitizationConfig {
395    fn default() -> Self {
396        Self {
397            enabled:                     false,
398            hide_implementation_details: true,
399            sanitize_database_errors:    true,
400            custom_error_message:        None,
401        }
402    }
403}
404
405/// Default per-map bucket ceiling: ~20 `MiB` of tracking state at ~200 bytes a bucket.
406///
407/// Named rather than inlined because the server's `RateLimitConfig` must agree with it —
408/// the two defaults living in two files with different values is what #977 fixed for the
409/// rest of this section.
410pub const DEFAULT_RATE_LIMIT_MAX_BUCKETS: usize = 100_000;
411
412/// Per-endpoint and global rate limiting configuration for `[security.rate_limiting]`.
413///
414/// The single shape for this section: the CLI authors it from TOML and the
415/// server consumes it from the compiled schema, so the two cannot drift (before
416/// #977 each side kept its own copy — with different defaults).
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(default, deny_unknown_fields)]
419pub struct RateLimitingSecurityConfig {
420    /// Enable rate limiting.
421    pub enabled: bool,
422    /// Global request rate cap (requests per second, per IP).
423    pub requests_per_second: u32,
424    /// Burst allowance above the steady-state rate.
425    pub burst_size: u32,
426    /// Auth initiation endpoint — max requests per window.
427    pub auth_start_max_requests: u32,
428    /// Auth initiation window in seconds.
429    pub auth_start_window_secs: u64,
430    /// OAuth callback endpoint — max requests per window.
431    pub auth_callback_max_requests: u32,
432    /// OAuth callback window in seconds.
433    pub auth_callback_window_secs: u64,
434    /// Token refresh endpoint — max requests per window.
435    pub auth_refresh_max_requests: u32,
436    /// Token refresh window in seconds.
437    pub auth_refresh_window_secs: u64,
438    /// Logout endpoint — max requests per window (#893).
439    pub auth_logout_max_requests: u32,
440    /// Logout window in seconds.
441    pub auth_logout_window_secs: u64,
442    /// Maximum failed first-factor login attempts before lockout.
443    ///
444    /// The off-the-shelf binary performs no first-factor login of its own, so it
445    /// cannot enforce this; a value tuned away from the default is rejected at
446    /// startup in production (#356) — see the server's `failed_login_lockout_check`.
447    pub failed_login_max_attempts: u32,
448    /// Lockout window in seconds after `failed_login_max_attempts` is exceeded.
449    pub failed_login_lockout_secs: u64,
450    /// Per-authenticated-user request rate in requests/second.
451    /// Defaults to 10× `requests_per_second` if not set.
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub requests_per_second_per_user: Option<u32>,
454    /// Redis URL for distributed rate limiting.
455    ///
456    /// Optional: when unset, budgets are tracked in-memory, per process. When
457    /// set, an unreachable Redis is a **boot error** in production (#770/#777
458    /// class) — the server does not silently downgrade to per-process limits.
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub redis_url: Option<String>,
461    /// Trust `X-Real-IP` / `X-Forwarded-For` headers for client IP extraction.
462    ///
463    /// Set to `true` only when FraiseQL is deployed behind a trusted reverse
464    /// proxy that sets these headers. Enabling without a trusted proxy allows
465    /// clients to spoof their IP address.
466    pub trust_proxy_headers: bool,
467    /// CIDR ranges trusted as proxy IPs (e.g. `["10.0.0.0/8"]`).
468    ///
469    /// When set and `trust_proxy_headers = true`, `X-Forwarded-For` is only
470    /// honoured when the direct connection IP falls within one of these ranges.
471    /// When omitted with `trust_proxy_headers = true`, all proxy IPs are trusted
472    /// (less secure — the server emits a startup warning).
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub trusted_proxy_cidrs: Option<Vec<String>>,
475    /// Maximum tracked buckets per map — a **memory ceiling, not a security control**.
476    ///
477    /// The limiter keeps one token bucket per key in each of its maps (per-IP,
478    /// per-user, per-path-and-IP, per-tenant). This caps how many it holds at once,
479    /// at roughly 200 bytes each: the default 100 000 is about 20 `MiB` per map.
480    ///
481    /// Reaching the cap evicts the least-recently-used of a sample rather than
482    /// refusing the newcomer (#1080/#1143), so setting it low degrades *accuracy* —
483    /// a client whose bucket was evicted starts again with a full one — and never
484    /// availability. It is a knob to size to the host, not to tune for protection.
485    pub max_buckets: usize,
486}
487
488impl Default for RateLimitingSecurityConfig {
489    fn default() -> Self {
490        Self {
491            enabled: false,
492            requests_per_second: 100,
493            requests_per_second_per_user: None,
494            burst_size: 200,
495            auth_start_max_requests: 5,
496            auth_start_window_secs: 60,
497            auth_callback_max_requests: 10,
498            auth_callback_window_secs: 60,
499            auth_refresh_max_requests: 20,
500            auth_refresh_window_secs: 300,
501            auth_logout_max_requests: 30,
502            auth_logout_window_secs: 60,
503            failed_login_max_attempts: 10,
504            failed_login_lockout_secs: 900,
505            redis_url: None,
506            trust_proxy_headers: false,
507            trusted_proxy_cidrs: None,
508            max_buckets: DEFAULT_RATE_LIMIT_MAX_BUCKETS,
509        }
510    }
511}
512
513/// AEAD algorithm for OAuth state and PKCE state blobs.
514#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
515#[non_exhaustive]
516pub enum EncryptionAlgorithm {
517    /// ChaCha20-Poly1305 (recommended — constant-time, software-friendly).
518    #[default]
519    #[serde(rename = "chacha20-poly1305")]
520    Chacha20Poly1305,
521    /// AES-256-GCM (hardware-accelerated on modern CPUs).
522    #[serde(rename = "aes-256-gcm")]
523    Aes256Gcm,
524}
525
526impl std::fmt::Display for EncryptionAlgorithm {
527    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528        match self {
529            Self::Chacha20Poly1305 => f.write_str("chacha20-poly1305"),
530            Self::Aes256Gcm => f.write_str("aes-256-gcm"),
531        }
532    }
533}
534
535/// Where the state-encryption key is sourced from.
536#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
537#[serde(rename_all = "lowercase")]
538#[non_exhaustive]
539pub enum KeySource {
540    /// Read the key from an environment variable.
541    #[default]
542    Env,
543}
544
545/// AEAD encryption for the OAuth state parameter and PKCE code challenges.
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547#[serde(default, deny_unknown_fields)]
548pub struct StateEncryptionConfig {
549    /// Enable state encryption.
550    pub enabled:    bool,
551    /// AEAD algorithm to use.
552    pub algorithm:  EncryptionAlgorithm,
553    /// Where to source the encryption key.
554    pub key_source: KeySource,
555    /// Environment variable holding the 32-byte hex-encoded key.
556    pub key_env:    Option<String>,
557}
558
559impl Default for StateEncryptionConfig {
560    fn default() -> Self {
561        Self {
562            enabled:    false,
563            algorithm:  EncryptionAlgorithm::default(),
564            key_source: KeySource::Env,
565            key_env:    Some("STATE_ENCRYPTION_KEY".to_string()),
566        }
567    }
568}
569
570/// PKCE code challenge method.
571#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
572#[non_exhaustive]
573pub enum CodeChallengeMethod {
574    /// SHA-256 (required in production).
575    #[default]
576    #[serde(rename = "S256")]
577    S256,
578    /// Plain (spec-allowed but insecure — warns at runtime).
579    #[serde(rename = "plain")]
580    Plain,
581}
582
583impl CodeChallengeMethod {
584    /// The method name as it appears on the wire (`S256` / `plain`).
585    #[must_use]
586    pub const fn as_str(&self) -> &'static str {
587        match self {
588            Self::S256 => "S256",
589            Self::Plain => "plain",
590        }
591    }
592}
593
594/// PKCE (Proof Key for Code Exchange) configuration for `[security.pkce]`.
595///
596/// Requires `state_encryption` to be enabled for secure state storage.
597#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
598#[serde(default, deny_unknown_fields)]
599pub struct PkceSecurityConfig {
600    /// Enable PKCE for OAuth Authorization Code flows.
601    pub enabled:               bool,
602    /// Code challenge method (`S256` recommended).
603    pub code_challenge_method: CodeChallengeMethod,
604    /// How long the PKCE state is valid before the auth flow expires (seconds).
605    pub state_ttl_secs:        u64,
606    /// Redis URL for distributed PKCE state storage across multiple replicas.
607    ///
608    /// Required for multi-replica deployments; without Redis, `/auth/start` and
609    /// `/auth/callback` must hit the same replica. Requires the `redis-pkce`
610    /// Cargo feature.
611    #[serde(skip_serializing_if = "Option::is_none")]
612    pub redis_url:             Option<String>,
613}
614
615impl Default for PkceSecurityConfig {
616    fn default() -> Self {
617        Self {
618            enabled:               false,
619            code_challenge_method: CodeChallengeMethod::S256,
620            state_ttl_secs:        600,
621            redis_url:             None,
622        }
623    }
624}
625
626/// API key authentication configuration for `[security.api_keys]`.
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
628#[serde(default, deny_unknown_fields)]
629pub struct ApiKeySecurityConfig {
630    /// Enable API key authentication.
631    pub enabled:        bool,
632    /// HTTP header name to read the API key from.
633    pub header:         String,
634    /// Hash algorithm for key verification (`sha256`).
635    pub hash_algorithm: String,
636    /// Storage backend: `"env"` for static keys, `"postgres"` for DB-backed.
637    pub storage:        String,
638    /// Static API key entries (only for `storage = "env"`).
639    #[serde(rename = "static")]
640    pub static_keys:    Vec<StaticApiKeyEntry>,
641}
642
643impl Default for ApiKeySecurityConfig {
644    fn default() -> Self {
645        Self {
646            enabled:        false,
647            header:         "X-API-Key".to_string(),
648            hash_algorithm: "sha256".to_string(),
649            storage:        "env".to_string(),
650            static_keys:    vec![],
651        }
652    }
653}
654
655/// A single static API key entry.
656#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
657#[serde(deny_unknown_fields)]
658pub struct StaticApiKeyEntry {
659    /// Hex-encoded hash, optionally prefixed with the algorithm (`sha256:…`).
660    pub key_hash: String,
661    /// OAuth-style scopes granted by this key.
662    #[serde(default)]
663    pub scopes:   Vec<String>,
664    /// Human-readable name (for audit logging).
665    pub name:     String,
666}
667
668/// Trusted document enforcement mode.
669#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
670#[serde(rename_all = "lowercase")]
671#[non_exhaustive]
672pub enum TrustedDocumentMode {
673    /// Only documentId requests allowed; raw query strings rejected.
674    Strict,
675    /// documentId requests use the manifest; raw queries fall through.
676    #[default]
677    Permissive,
678}
679
680/// Trusted documents / query allowlist configuration for
681/// `[security.trusted_documents]`.
682#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
683#[serde(default, deny_unknown_fields)]
684pub struct TrustedDocumentsConfig {
685    /// Enable trusted documents.
686    pub enabled:              bool,
687    /// Enforcement mode: `strict` or `permissive`.
688    pub mode:                 TrustedDocumentMode,
689    /// Path to the trusted documents manifest JSON file.
690    #[serde(skip_serializing_if = "Option::is_none")]
691    pub manifest_path:        Option<String>,
692    /// URL to fetch the trusted documents manifest from at startup.
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub manifest_url:         Option<String>,
695    /// Poll interval in seconds for hot-reloading the manifest (0 = no reload).
696    pub reload_interval_secs: u64,
697}
698
699impl Default for TrustedDocumentsConfig {
700    fn default() -> Self {
701        Self {
702            enabled:              false,
703            mode:                 TrustedDocumentMode::Permissive,
704            manifest_path:        None,
705            manifest_url:         None,
706            reload_interval_secs: 0,
707        }
708    }
709}
710
711/// Token revocation configuration for `[security.token_revocation]`.
712#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
713#[serde(default, deny_unknown_fields)]
714pub struct TokenRevocationSecurityConfig {
715    /// Enable token revocation.
716    pub enabled:             bool,
717    /// Storage backend: `"redis"`, `"postgres"`, or `"memory"`.
718    pub backend:             String,
719    /// Reject JWTs without a `jti` claim when revocation is enabled.
720    pub require_jti:         bool,
721    /// If the revocation store is unreachable: `false` = reject (fail-closed),
722    /// `true` = allow (fail-open).
723    pub fail_open:           bool,
724    /// Redis URL for distributed revocation (inherited from `[fraiseql.redis]`
725    /// if absent).
726    #[serde(skip_serializing_if = "Option::is_none")]
727    pub redis_url:           Option<String>,
728    /// How long (seconds) a `revoke-all` epoch is retained.
729    ///
730    /// `revoke-all` records a per-user epoch rather than deleting individual
731    /// tokens, so the entry must outlive every token that could have been issued
732    /// before the revocation: set this **above your maximum access-token
733    /// lifetime**. Once it expires a pre-revocation token would resume working
734    /// (until its own `exp`). Default: 86400 (24h).
735    pub revoke_all_ttl_secs: u64,
736}
737
738impl Default for TokenRevocationSecurityConfig {
739    fn default() -> Self {
740        Self {
741            enabled:             false,
742            backend:             "memory".to_string(),
743            require_jti:         true,
744            fail_open:           false,
745            redis_url:           None,
746            revoke_all_ttl_secs: 86_400,
747        }
748    }
749}
750
751/// A machine service account (`security.service_accounts.<name>`).
752///
753/// The bearer secret is **never** inlined — `secret_env` names the environment
754/// variable that holds it.
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756#[serde(deny_unknown_fields)]
757pub struct ServiceAccountConfig {
758    /// Name of the environment variable holding the plaintext bearer secret.
759    pub secret_env:      String,
760    /// The `run_as` ceiling — roles granted. Empty ⇒ no role authority.
761    #[serde(default)]
762    pub roles:           Vec<String>,
763    /// The `run_as` ceiling — scopes granted. Empty ⇒ no scope authority.
764    #[serde(default)]
765    pub scopes:          Vec<String>,
766    /// Optional tenant pin. Omitted ⇒ global / NULL tenant.
767    #[serde(default)]
768    pub tenant:          Option<String>,
769    /// Optional server-injected `fraiseql.enriched.*` fields, the **only**
770    /// sanctioned deviation from uniform enrichment (ADR-0016 decision 6 /
771    /// ADR-0018 decision 5) — for a daemon with no natural actor row.
772    /// Server-injected, never token-asserted.
773    #[serde(default)]
774    pub static_enriched: HashMap<String, serde_json::Value>,
775}
776
777/// Compiled `[security.cost_budget]` (#379).
778///
779/// `deny_unknown_fields`: a typo'd budget key must fail the load, not silently
780/// leave the budget unenforced (the compiled-schema seam rule).
781#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
782#[serde(deny_unknown_fields)]
783pub struct CostBudgetConfig {
784    /// Hard ceiling on a single operation's estimated cost, enforced in the
785    /// executor's dispatch for **every** transport. `None` = no ceiling.
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub per_request_max: Option<u64>,
788
789    /// Default rolling per-minute cost budget for registered tenants that do
790    /// not set their own `cost_budget_per_minute`. `None` = no default.
791    #[serde(default, skip_serializing_if = "Option::is_none")]
792    pub per_tenant_per_minute_default: Option<u64>,
793}
794
795impl SecurityConfig {
796    /// Create a new empty security configuration.
797    #[must_use]
798    pub fn new() -> Self {
799        Self::default()
800    }
801
802    /// Add a role definition.
803    pub fn add_role(&mut self, role: RoleDefinition) {
804        self.role_definitions.push(role);
805    }
806
807    /// Find a role definition by name.
808    #[must_use]
809    pub fn find_role(&self, name: &str) -> Option<&RoleDefinition> {
810        self.role_definitions.iter().find(|r| r.name == name)
811    }
812
813    /// Get all scopes granted to a role.
814    #[must_use]
815    pub fn get_role_scopes(&self, role_name: &str) -> Vec<String> {
816        self.find_role(role_name)
817            .map(|role| role.scopes.iter().map(|s| s.to_string()).collect::<Vec<String>>())
818            .unwrap_or_default()
819    }
820
821    /// Check if a role has a specific scope.
822    #[must_use]
823    pub fn role_has_scope(&self, role_name: &str, scope: &str) -> bool {
824        self.find_role(role_name).is_some_and(|role| role.has_scope(scope))
825    }
826}