use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SecuritySettings {
pub default_policy: Option<String>,
pub rules: Vec<AuthorizationRule>,
pub policies: Vec<AuthorizationPolicy>,
pub field_auth: Vec<FieldAuthRule>,
pub enterprise: EnterpriseSecurityConfig,
pub error_sanitization: Option<ErrorSanitizationTomlConfig>,
pub rate_limiting: Option<RateLimitingSecurityConfig>,
pub state_encryption: Option<StateEncryptionConfig>,
pub pkce: Option<PkceConfig>,
pub api_keys: Option<ApiKeySecurityConfig>,
pub token_revocation: Option<TokenRevocationSecurityConfig>,
pub trusted_documents: Option<TrustedDocumentsConfig>,
pub persisted_queries_only: bool,
}
impl Default for SecuritySettings {
fn default() -> Self {
Self {
default_policy: Some("authenticated".to_string()),
rules: vec![],
policies: vec![],
field_auth: vec![],
enterprise: EnterpriseSecurityConfig::default(),
error_sanitization: None,
rate_limiting: None,
state_encryption: None,
pkce: None,
api_keys: None,
token_revocation: None,
trusted_documents: None,
persisted_queries_only: false,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AuthorizationRule {
pub name: String,
pub rule: String,
pub description: Option<String>,
#[serde(default)]
pub cacheable: bool,
pub cache_ttl_seconds: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AuthorizationPolicy {
pub name: String,
#[serde(rename = "type")]
pub policy_type: String,
pub rule: Option<String>,
pub roles: Vec<String>,
pub strategy: Option<String>,
#[serde(default)]
pub attributes: Vec<String>,
pub description: Option<String>,
pub cache_ttl_seconds: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FieldAuthRule {
pub type_name: String,
pub field_name: String,
pub policy: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EnterpriseSecurityConfig {
pub rate_limiting_enabled: bool,
pub auth_endpoint_max_requests: u32,
pub auth_endpoint_window_seconds: u64,
pub audit_logging_enabled: bool,
pub audit_log_backend: String,
pub audit_retention_days: u32,
pub error_sanitization: bool,
pub hide_implementation_details: bool,
pub constant_time_comparison: bool,
pub pkce_enabled: bool,
}
impl Default for EnterpriseSecurityConfig {
fn default() -> Self {
Self {
rate_limiting_enabled: true,
auth_endpoint_max_requests: 100,
auth_endpoint_window_seconds: 60,
audit_logging_enabled: true,
audit_log_backend: "postgresql".to_string(),
audit_retention_days: 365,
error_sanitization: true,
hide_implementation_details: true,
constant_time_comparison: true,
pkce_enabled: true,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ErrorSanitizationTomlConfig {
pub enabled: bool,
#[serde(default = "default_true")]
pub hide_implementation_details: bool,
#[serde(default = "default_true")]
pub sanitize_database_errors: bool,
pub custom_error_message: Option<String>,
}
impl Default for ErrorSanitizationTomlConfig {
fn default() -> Self {
Self {
enabled: false,
hide_implementation_details: true,
sanitize_database_errors: true,
custom_error_message: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct RateLimitingSecurityConfig {
pub enabled: bool,
pub requests_per_second: u32,
pub burst_size: u32,
pub auth_start_max_requests: u32,
pub auth_start_window_secs: u64,
pub auth_callback_max_requests: u32,
pub auth_callback_window_secs: u64,
pub auth_refresh_max_requests: u32,
pub auth_refresh_window_secs: u64,
pub auth_logout_max_requests: u32,
pub auth_logout_window_secs: u64,
pub failed_login_max_attempts: u32,
pub failed_login_lockout_secs: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requests_per_second_per_user: Option<u32>,
pub redis_url: Option<String>,
#[serde(default)]
pub trust_proxy_headers: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_proxy_cidrs: Option<Vec<String>>,
}
impl Default for RateLimitingSecurityConfig {
fn default() -> Self {
Self {
enabled: false,
requests_per_second: 100,
requests_per_second_per_user: None,
burst_size: 200,
auth_start_max_requests: 5,
auth_start_window_secs: 60,
auth_callback_max_requests: 10,
auth_callback_window_secs: 60,
auth_refresh_max_requests: 20,
auth_refresh_window_secs: 300,
auth_logout_max_requests: 30,
auth_logout_window_secs: 60,
failed_login_max_attempts: 10,
failed_login_lockout_secs: 900,
redis_url: None,
trust_proxy_headers: false,
trusted_proxy_cidrs: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub enum EncryptionAlgorithm {
#[default]
#[serde(rename = "chacha20-poly1305")]
Chacha20Poly1305,
#[serde(rename = "aes-256-gcm")]
Aes256Gcm,
}
impl fmt::Display for EncryptionAlgorithm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Chacha20Poly1305 => f.write_str("chacha20-poly1305"),
Self::Aes256Gcm => f.write_str("aes-256-gcm"),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum KeySource {
#[default]
Env,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct StateEncryptionConfig {
pub enabled: bool,
pub algorithm: EncryptionAlgorithm,
pub key_source: KeySource,
pub key_env: Option<String>,
}
impl Default for StateEncryptionConfig {
fn default() -> Self {
Self {
enabled: false,
algorithm: EncryptionAlgorithm::default(),
key_source: KeySource::Env,
key_env: Some("STATE_ENCRYPTION_KEY".to_string()),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[non_exhaustive]
pub enum CodeChallengeMethod {
#[default]
#[serde(rename = "S256")]
S256,
#[serde(rename = "plain")]
Plain,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct PkceConfig {
pub enabled: bool,
pub code_challenge_method: CodeChallengeMethod,
pub state_ttl_secs: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub redis_url: Option<String>,
}
impl Default for PkceConfig {
fn default() -> Self {
Self {
enabled: false,
code_challenge_method: CodeChallengeMethod::S256,
state_ttl_secs: 600,
redis_url: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ApiKeySecurityConfig {
pub enabled: bool,
pub header: String,
pub hash_algorithm: String,
pub storage: String,
#[serde(default, rename = "static")]
pub static_keys: Vec<StaticApiKeyEntry>,
}
impl Default for ApiKeySecurityConfig {
fn default() -> Self {
Self {
enabled: false,
header: "X-API-Key".to_string(),
hash_algorithm: "sha256".to_string(),
storage: "env".to_string(),
static_keys: vec![],
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct StaticApiKeyEntry {
pub key_hash: String,
#[serde(default)]
pub scopes: Vec<String>,
pub name: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum TrustedDocumentMode {
Strict,
#[default]
Permissive,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct TrustedDocumentsConfig {
pub enabled: bool,
pub mode: TrustedDocumentMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest_url: Option<String>,
#[serde(default)]
pub reload_interval_secs: u64,
}
impl Default for TrustedDocumentsConfig {
fn default() -> Self {
Self {
enabled: false,
mode: TrustedDocumentMode::Permissive,
manifest_path: None,
manifest_url: None,
reload_interval_secs: 0,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct TokenRevocationSecurityConfig {
pub enabled: bool,
pub backend: String,
#[serde(default = "default_true")]
pub require_jti: bool,
#[serde(default)]
pub fail_open: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub redis_url: Option<String>,
pub revoke_all_ttl_secs: u64,
}
impl Default for TokenRevocationSecurityConfig {
fn default() -> Self {
Self {
enabled: false,
backend: "memory".to_string(),
require_jti: true,
fail_open: false,
redis_url: None,
revoke_all_ttl_secs: 86_400,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct OidcClientConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audience: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub discovery_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_redirect_uri: Option<String>,
}
impl OidcClientConfig {
pub fn validate(&self) -> anyhow::Result<()> {
let client_fields = [
("discovery_url", self.discovery_url.is_some()),
("client_id", self.client_id.is_some()),
("client_secret_env", self.client_secret_env.is_some()),
("server_redirect_uri", self.server_redirect_uri.is_some()),
];
let client_set = client_fields.iter().filter(|(_, set)| *set).count();
let has_client_group = client_set > 0;
let has_jwt_group = self.issuer.is_some();
if has_client_group && client_set < client_fields.len() {
let missing: Vec<&str> =
client_fields.iter().filter(|(_, set)| !*set).map(|(name, _)| *name).collect();
anyhow::bail!(
"[auth] PKCE OAuth-client config is incomplete: discovery_url, client_id, \
client_secret_env, and server_redirect_uri must be set together (missing: {}).",
missing.join(", ")
);
}
if has_client_group {
anyhow::bail!(
"[auth] PKCE OAuth-client config (discovery_url, client_id, client_secret_env, \
server_redirect_uri) is recognized but not yet functional on the compiled path: \
the compiled schema carries no auth/auth_endpoints for the server to consume. \
Remove these fields and use [auth] issuer/audience for JWT validation, or track \
the wiring in #621."
);
}
if self.audience.is_some() && !has_jwt_group {
anyhow::bail!(
"[auth] audience is set but issuer is not. JWT validation requires issuer — \
add issuer, or remove audience."
);
}
if !has_jwt_group {
anyhow::bail!(
"[auth] is empty. Configure JWT validation with issuer (audience optional). \
An empty [auth] block does nothing."
);
}
Ok(())
}
}
fn default_true() -> bool {
true
}