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