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