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