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 pub fn with_description(mut self, description: String) -> Self {
72 self.description = Some(description);
73 self
74 }
75
76 /// Check if this role has a specific scope.
77 ///
78 /// Supports exact matching and wildcard patterns:
79 /// - `read:User.email` matches exactly
80 /// - `read:*` matches any scope starting with "read:"
81 /// - `read:User.*` matches "read:User.email", "read:User.name", etc.
82 /// - `admin:*` matches any admin scope
83 #[must_use]
84 pub fn has_scope(&self, required_scope: &str) -> bool {
85 self.scopes.iter().any(|scope| {
86 let scope = scope.as_str();
87 if scope == "*" {
88 return true; // Wildcard matches everything
89 }
90
91 if scope == required_scope {
92 return true; // Exact match
93 }
94
95 // Handle wildcard patterns like "read:*" or "admin:*"
96 if let Some(prefix) = scope.strip_suffix(":*") {
97 return required_scope.starts_with(prefix) && required_scope.contains(':');
98 }
99
100 // Handle Type.* wildcard patterns like "read:User.*"
101 if let Some(prefix) = scope.strip_suffix('*') {
102 return required_scope.starts_with(prefix);
103 }
104
105 false
106 })
107 }
108}
109
110/// Security configuration from fraiseql.toml.
111///
112/// Contains role definitions and other security-related settings
113/// that are compiled into schema.compiled.json.
114#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
115pub struct SecurityConfig {
116 /// Role definitions mapping role names to their granted scopes.
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
118 pub role_definitions: Vec<RoleDefinition>,
119
120 /// Default role when none is specified.
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub default_role: Option<String>,
123
124 /// Whether this schema serves multiple tenants with data isolation via RLS.
125 ///
126 /// When `true` and caching is enabled, FraiseQL verifies that Row-Level Security
127 /// is active on the database at startup. This prevents silent cross-tenant data
128 /// leakage through the cache.
129 ///
130 /// Set `rls_enforcement` in `CacheConfig` to control whether a missing RLS check
131 /// causes a startup failure or only emits a warning.
132 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
133 pub multi_tenant: bool,
134
135 /// Additional security settings (rate limiting, audit logging, etc.)
136 #[serde(flatten)]
137 pub additional: HashMap<String, serde_json::Value>,
138}
139
140impl SecurityConfig {
141 /// Create a new empty security configuration.
142 #[must_use]
143 pub fn new() -> Self {
144 Self::default()
145 }
146
147 /// Add a role definition.
148 pub fn add_role(&mut self, role: RoleDefinition) {
149 self.role_definitions.push(role);
150 }
151
152 /// Find a role definition by name.
153 #[must_use]
154 pub fn find_role(&self, name: &str) -> Option<&RoleDefinition> {
155 self.role_definitions.iter().find(|r| r.name == name)
156 }
157
158 /// Get all scopes granted to a role.
159 #[must_use]
160 pub fn get_role_scopes(&self, role_name: &str) -> Vec<String> {
161 self.find_role(role_name)
162 .map(|role| role.scopes.iter().map(|s| s.to_string()).collect::<Vec<String>>())
163 .unwrap_or_default()
164 }
165
166 /// Check if a role has a specific scope.
167 #[must_use]
168 pub fn role_has_scope(&self, role_name: &str, scope: &str) -> bool {
169 self.find_role(role_name).is_some_and(|role| role.has_scope(scope))
170 }
171}