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/// Source from which an injected SQL parameter is resolved at runtime.
13///
14/// Injected parameters are not exposed in the GraphQL schema. They are
15/// silently added to SQL queries and function calls, resolved from the
16/// authenticated request context.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(tag = "source", content = "claim", rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum InjectedParamSource {
21    /// Extract a value from the JWT claims.
22    ///
23    /// Special aliases resolved before attribute lookup:
24    /// - `"sub"` → `SecurityContext.user_id`
25    /// - `"tenant_id"` / `"org_id"` → `SecurityContext.tenant_id`
26    /// - any other name → `SecurityContext.attributes.get(name)`
27    Jwt(String),
28}
29
30/// Role definition for field-level RBAC.
31///
32/// Defines which GraphQL scopes a role grants access to.
33/// Used by the runtime to determine which fields a user can access
34/// based on their assigned roles.
35///
36/// # Example
37///
38/// ```json
39/// {
40///   "name": "admin",
41///   "description": "Administrator with all scopes",
42///   "scopes": ["admin:*"]
43/// }
44/// ```
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct RoleDefinition {
47    /// Role name (e.g., "admin", "user", "viewer").
48    pub name: RoleName,
49
50    /// Optional role description for documentation.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub description: Option<String>,
53
54    /// List of scopes this role grants access to.
55    /// Scopes follow the format: `action:resource` (e.g., "read:User.email", "admin:*")
56    pub scopes: Vec<Scope>,
57}
58
59impl RoleDefinition {
60    /// Create a new role definition.
61    #[must_use]
62    pub fn new(name: impl Into<String>, scopes: Vec<String>) -> Self {
63        Self {
64            name:        RoleName::new(name),
65            description: None,
66            scopes:      scopes.into_iter().map(Scope::new).collect(),
67        }
68    }
69
70    /// Add a description to the role.
71    #[must_use]
72    pub fn with_description(mut self, description: String) -> Self {
73        self.description = Some(description);
74        self
75    }
76
77    /// Check if this role has a specific scope.
78    ///
79    /// Supports exact matching and wildcard patterns:
80    /// - `read:User.email` matches exactly
81    /// - `read:*` matches any scope starting with "read:"
82    /// - `read:User.*` matches "read:User.email", "read:User.name", etc.
83    /// - `admin:*` matches any admin scope
84    #[must_use]
85    pub fn has_scope(&self, required_scope: &str) -> bool {
86        self.scopes.iter().any(|scope| {
87            let scope = scope.as_str();
88            if scope == "*" {
89                return true; // Wildcard matches everything
90            }
91
92            if scope == required_scope {
93                return true; // Exact match
94            }
95
96            // Handle wildcard patterns like "read:*" or "admin:*"
97            if let Some(prefix) = scope.strip_suffix(":*") {
98                return required_scope.starts_with(prefix) && required_scope.contains(':');
99            }
100
101            // Handle Type.* wildcard patterns like "read:User.*"
102            if let Some(prefix) = scope.strip_suffix('*') {
103                return required_scope.starts_with(prefix);
104            }
105
106            false
107        })
108    }
109}
110
111/// Tenancy isolation mode for multi-tenant deployments.
112///
113/// Determines how tenant data is separated at the database level.
114/// Configured via `[fraiseql.tenancy]` in `fraiseql.toml`.
115#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117#[non_exhaustive]
118pub enum TenancyMode {
119    /// Single-tenant deployment, no isolation machinery.
120    #[default]
121    None,
122
123    /// Row-level isolation: shared tables with `@tenant_id` column injection.
124    ///
125    /// The compiler validates that all queries/mutations referencing
126    /// `@tenant_id`-annotated types have `inject_params` wired correctly.
127    /// At runtime, `InjectedParamSource::Jwt` resolves the tenant claim
128    /// and injects a WHERE clause.
129    Row,
130
131    /// Schema-level isolation: per-tenant PostgreSQL schemas.
132    ///
133    /// The adapter issues `SET search_path TO tenant_{key}, public` on
134    /// connection acquisition. `TenantExecutorFactory` provisions/drops
135    /// schemas via DDL.
136    Schema,
137}
138
139impl std::fmt::Display for TenancyMode {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            Self::None => write!(f, "none"),
143            Self::Row => write!(f, "row"),
144            Self::Schema => write!(f, "schema"),
145        }
146    }
147}
148
149/// Tenancy configuration for multi-tenant deployments.
150///
151/// Compiled from `[fraiseql.tenancy]` in `fraiseql.toml` into the
152/// `security.tenancy` section of `schema.compiled.json`.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct TenancyConfig {
155    /// Isolation strategy: `"none"`, `"row"`, or `"schema"`.
156    #[serde(default)]
157    pub mode: TenancyMode,
158
159    /// JWT claim name that carries the tenant identifier.
160    ///
161    /// Defaults to `"tenant_id"`. Used by `InjectedParamSource::Jwt` to
162    /// resolve the tenant at runtime, and by the compiler to validate
163    /// `@tenant_id` annotations in row mode.
164    #[serde(default = "default_tenant_claim")]
165    pub tenant_claim: String,
166}
167
168fn default_tenant_claim() -> String {
169    "tenant_id".to_string()
170}
171
172impl Default for TenancyConfig {
173    fn default() -> Self {
174        Self {
175            mode:         TenancyMode::None,
176            tenant_claim: default_tenant_claim(),
177        }
178    }
179}
180
181/// Returns `true` when tenancy config equals the default (mode=none, `claim=tenant_id`).
182///
183/// Used by serde to skip serializing the tenancy field when it carries no information.
184fn is_default_tenancy(t: &TenancyConfig) -> bool {
185    *t == TenancyConfig::default()
186}
187
188/// Security configuration from fraiseql.toml.
189///
190/// Contains role definitions and other security-related settings
191/// that are compiled into schema.compiled.json.
192#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
193pub struct SecurityConfig {
194    /// Role definitions mapping role names to their granted scopes.
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    pub role_definitions: Vec<RoleDefinition>,
197
198    /// Default role when none is specified.
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub default_role: Option<String>,
201
202    /// Whether this schema serves multiple tenants with data isolation via RLS.
203    ///
204    /// When `true` and caching is enabled, FraiseQL verifies that Row-Level Security
205    /// is active on the database at startup. This prevents silent cross-tenant data
206    /// leakage through the cache.
207    ///
208    /// Set `rls_enforcement` in `CacheConfig` to control whether a missing RLS check
209    /// causes a startup failure or only emits a warning.
210    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
211    pub multi_tenant: bool,
212
213    /// Tenancy isolation configuration for multi-tenant deployments.
214    ///
215    /// When present and `mode != "none"`, the runtime enforces tenant isolation
216    /// at the database level (row-based or schema-based).
217    #[serde(default, skip_serializing_if = "is_default_tenancy")]
218    pub tenancy: TenancyConfig,
219
220    /// Additional security settings (rate limiting, audit logging, etc.)
221    #[serde(flatten)]
222    pub additional: HashMap<String, serde_json::Value>,
223}
224
225impl SecurityConfig {
226    /// Create a new empty security configuration.
227    #[must_use]
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    /// Add a role definition.
233    pub fn add_role(&mut self, role: RoleDefinition) {
234        self.role_definitions.push(role);
235    }
236
237    /// Find a role definition by name.
238    #[must_use]
239    pub fn find_role(&self, name: &str) -> Option<&RoleDefinition> {
240        self.role_definitions.iter().find(|r| r.name == name)
241    }
242
243    /// Get all scopes granted to a role.
244    #[must_use]
245    pub fn get_role_scopes(&self, role_name: &str) -> Vec<String> {
246        self.find_role(role_name)
247            .map(|role| role.scopes.iter().map(|s| s.to_string()).collect::<Vec<String>>())
248            .unwrap_or_default()
249    }
250
251    /// Check if a role has a specific scope.
252    #[must_use]
253    pub fn role_has_scope(&self, role_name: &str, scope: &str) -> bool {
254        self.find_role(role_name).is_some_and(|role| role.has_scope(scope))
255    }
256}