Skip to main content

reinhardt_admin/server/
validation.rs

1//! Input validation for mutation operations
2//!
3//! This module provides validation utilities to ensure that incoming mutation
4//! requests (create/update) are safe and conform to the model's field definitions.
5//!
6//! # Security Protections
7//!
8//! - **Field allowlist**: Only fields defined in `ModelAdmin.fields()` or `list_display()` are allowed
9//! - **Readonly enforcement**: Fields in `readonly_fields()` cannot be modified
10//! - **Type validation**: Values are checked for basic type compatibility
11//! - **Size limits**: Payload size and field counts are limited to prevent DoS
12
13use crate::core::ModelAdmin;
14use crate::types::AdminError;
15use std::collections::HashMap;
16
17pub(crate) fn retain_allowed_fields<T: AsRef<str>>(
18	data: &mut HashMap<String, serde_json::Value>,
19	allowed_fields: &[T],
20) {
21	data.retain(|field, _| {
22		allowed_fields
23			.iter()
24			.any(|allowed| field == allowed.as_ref())
25	});
26}
27
28/// Maximum number of fields in a mutation request
29const MAX_FIELDS: usize = 100;
30
31/// Maximum string length for a single field value (in bytes)
32const MAX_STRING_LENGTH: usize = 1_000_000; // 1MB
33
34/// Maximum total payload size (in bytes, approximate)
35const MAX_PAYLOAD_SIZE: usize = 10_000_000; // 10MB
36
37/// Validates mutation data against model admin configuration.
38///
39/// This function performs the following checks:
40/// 1. Size limits (field count, string length, total payload)
41/// 2. Field allowlist (only known fields are allowed)
42/// 3. Readonly field enforcement (readonly fields cannot be modified)
43///
44/// # Arguments
45///
46/// * `data` - The mutation data to validate
47/// * `model_admin` - The model admin configuration
48/// * `is_update` - Whether this is an update operation (blocks pk_field modification on updates only)
49///
50/// # Errors
51///
52/// Returns `AdminError::ValidationError` if validation fails.
53///
54/// # Examples
55///
56/// ```ignore
57/// use reinhardt_admin::server::validation::validate_mutation_data;
58///
59/// let mut data = HashMap::new();
60/// data.insert("name".to_string(), serde_json::json!("Alice"));
61///
62/// validate_mutation_data(&data, &model_admin, false)?;
63/// ```
64pub fn validate_mutation_data(
65	data: &HashMap<String, serde_json::Value>,
66	model_admin: &dyn ModelAdmin,
67	is_update: bool,
68) -> Result<(), AdminError> {
69	// Check field count limit
70	validate_field_count(data)?;
71
72	// Check total payload size
73	validate_payload_size(data)?;
74
75	// Get allowed fields from model admin
76	let allowed_fields = get_allowed_fields(model_admin);
77	let readonly_fields: Vec<&str> = model_admin.readonly_fields();
78	let pk_field = model_admin.pk_field();
79
80	// Validate each field
81	for (field_name, value) in data {
82		// Check if field is in allowlist
83		validate_field_allowed(field_name, &allowed_fields)?;
84
85		// Check readonly fields (for both create and update)
86		if readonly_fields.contains(&field_name.as_str()) {
87			return Err(AdminError::ValidationError(format!(
88				"Field '{}' is read-only and cannot be modified",
89				field_name
90			)));
91		}
92
93		// Prevent primary key modification on update operations.
94		// On create, PK may be supplied by the caller (e.g. UUID-based PKs),
95		// so it is only blocked for updates where changing PK is never valid.
96		if is_update && field_name == pk_field {
97			return Err(AdminError::ValidationError(format!(
98				"Primary key field '{}' cannot be modified",
99				field_name
100			)));
101		}
102
103		// Validate value size
104		validate_value_size(field_name, value)?;
105	}
106
107	Ok(())
108}
109
110/// Gets the list of allowed fields from model admin.
111///
112/// Falls back to `list_display()` if `fields()` returns None.
113fn get_allowed_fields(model_admin: &dyn ModelAdmin) -> Vec<&str> {
114	model_admin
115		.fields()
116		.unwrap_or_else(|| model_admin.list_display())
117}
118
119/// Validates that the number of fields doesn't exceed the limit.
120fn validate_field_count(data: &HashMap<String, serde_json::Value>) -> Result<(), AdminError> {
121	if data.len() > MAX_FIELDS {
122		return Err(AdminError::ValidationError(format!(
123			"Too many fields in request: {} (max {})",
124			data.len(),
125			MAX_FIELDS
126		)));
127	}
128	Ok(())
129}
130
131/// Validates that the total payload size doesn't exceed the limit.
132fn validate_payload_size(data: &HashMap<String, serde_json::Value>) -> Result<(), AdminError> {
133	let total_size: usize = data
134		.iter()
135		.map(|(k, v)| k.len() + v.to_string().len())
136		.sum();
137
138	if total_size > MAX_PAYLOAD_SIZE {
139		return Err(AdminError::ValidationError(format!(
140			"Payload too large: {} bytes (max {} bytes)",
141			total_size, MAX_PAYLOAD_SIZE
142		)));
143	}
144	Ok(())
145}
146
147/// Validates that a field is in the allowed list.
148fn validate_field_allowed(field_name: &str, allowed_fields: &[&str]) -> Result<(), AdminError> {
149	if !allowed_fields.contains(&field_name) {
150		return Err(AdminError::ValidationError(format!(
151			"Field '{}' is not allowed. Allowed fields: {:?}",
152			field_name, allowed_fields
153		)));
154	}
155	Ok(())
156}
157
158/// Validates that a value doesn't exceed size limits.
159fn validate_value_size(field_name: &str, value: &serde_json::Value) -> Result<(), AdminError> {
160	match value {
161		serde_json::Value::String(s) => {
162			if s.len() > MAX_STRING_LENGTH {
163				return Err(AdminError::ValidationError(format!(
164					"Field '{}' value too long: {} bytes (max {} bytes)",
165					field_name,
166					s.len(),
167					MAX_STRING_LENGTH
168				)));
169			}
170		}
171		serde_json::Value::Array(arr) if arr.len() > MAX_FIELDS => {
172			return Err(AdminError::ValidationError(format!(
173				"Field '{}' array too large: {} elements (max {})",
174				field_name,
175				arr.len(),
176				MAX_FIELDS
177			)));
178		}
179		serde_json::Value::Object(obj) if obj.len() > MAX_FIELDS => {
180			return Err(AdminError::ValidationError(format!(
181				"Field '{}' object too large: {} keys (max {})",
182				field_name,
183				obj.len(),
184				MAX_FIELDS
185			)));
186		}
187		_ => {}
188	}
189	Ok(())
190}
191
192#[cfg(all(test, server))]
193mod tests {
194	use super::*;
195	use crate::core::ModelAdminConfig;
196	use rstest::rstest;
197
198	fn create_test_admin() -> ModelAdminConfig {
199		ModelAdminConfig::builder()
200			.model_name("TestModel")
201			.list_display(vec!["id", "name", "email", "created_at"])
202			.fields(vec!["id", "name", "email", "created_at"])
203			.readonly_fields(vec!["created_at"])
204			.build()
205			.unwrap()
206	}
207
208	#[rstest]
209	fn test_validate_empty_data() {
210		let admin = create_test_admin();
211		let data = HashMap::new();
212		assert!(validate_mutation_data(&data, &admin, false).is_ok());
213	}
214
215	#[rstest]
216	fn test_validate_allowed_field() {
217		let admin = create_test_admin();
218		let mut data = HashMap::new();
219		data.insert("name".to_string(), serde_json::json!("Alice"));
220
221		assert!(validate_mutation_data(&data, &admin, false).is_ok());
222	}
223
224	#[rstest]
225	fn retain_allowed_fields_removes_unconfigured_columns() {
226		let mut data = HashMap::from([
227			("id".to_string(), serde_json::json!(1)),
228			("name".to_string(), serde_json::json!("visible")),
229			("Name".to_string(), serde_json::json!("hidden")),
230			("reset_token".to_string(), serde_json::json!("secret")),
231		]);
232
233		retain_allowed_fields(&mut data, &["name"]);
234
235		assert_eq!(
236			data,
237			HashMap::from([("name".to_string(), serde_json::json!("visible"))])
238		);
239	}
240
241	#[rstest]
242	fn test_validate_disallowed_field() {
243		let admin = create_test_admin();
244		let mut data = HashMap::new();
245		data.insert("hacked".to_string(), serde_json::json!("value"));
246
247		let result = validate_mutation_data(&data, &admin, false);
248		assert!(result.is_err());
249		assert!(matches!(
250			result.unwrap_err(),
251			AdminError::ValidationError(_)
252		));
253	}
254
255	#[rstest]
256	fn test_validate_readonly_field() {
257		let admin = create_test_admin();
258		let mut data = HashMap::new();
259		data.insert("created_at".to_string(), serde_json::json!("2024-01-01"));
260
261		let result = validate_mutation_data(&data, &admin, false);
262		assert!(result.is_err());
263		let err = result.unwrap_err();
264		assert!(matches!(err, AdminError::ValidationError(_)));
265		assert!(err.to_string().contains("read-only"));
266	}
267
268	#[rstest]
269	fn test_validate_pk_field_on_update() {
270		let admin = create_test_admin();
271		let mut data = HashMap::new();
272		data.insert("id".to_string(), serde_json::json!(999));
273
274		let result = validate_mutation_data(&data, &admin, true);
275		assert!(result.is_err());
276		let err = result.unwrap_err();
277		assert!(matches!(err, AdminError::ValidationError(_)));
278		assert!(err.to_string().contains("Primary key"));
279	}
280
281	#[rstest]
282	fn test_validate_pk_field_on_create() {
283		// On create, PK may be supplied by the caller (e.g. UUID-based PKs)
284		let admin = create_test_admin();
285		let mut data = HashMap::new();
286		data.insert("id".to_string(), serde_json::json!(999));
287
288		let result = validate_mutation_data(&data, &admin, false);
289		assert!(result.is_ok());
290	}
291
292	#[rstest]
293	fn test_validate_too_many_fields() {
294		let admin = create_test_admin();
295		let mut data = HashMap::new();
296
297		// Create more fields than allowed (but use allowed field names)
298		for i in 0..=MAX_FIELDS {
299			data.insert(format!("name_{}", i), serde_json::json!("value"));
300		}
301
302		let result = validate_mutation_data(&data, &admin, false);
303		assert!(result.is_err());
304		let err = result.unwrap_err();
305		assert!(matches!(err, AdminError::ValidationError(_)));
306		assert!(err.to_string().contains("Too many fields"));
307	}
308
309	#[rstest]
310	fn test_validate_string_too_long() {
311		let admin = create_test_admin();
312		let mut data = HashMap::new();
313		data.insert(
314			"name".to_string(),
315			serde_json::json!("x".repeat(MAX_STRING_LENGTH + 1)),
316		);
317
318		let result = validate_mutation_data(&data, &admin, false);
319		assert!(result.is_err());
320		let err = result.unwrap_err();
321		assert!(matches!(err, AdminError::ValidationError(_)));
322		assert!(err.to_string().contains("too long"));
323	}
324
325	#[rstest]
326	fn test_validate_array_too_large() {
327		let admin = create_test_admin();
328		let mut data = HashMap::new();
329		let large_array: Vec<_> = (0..=MAX_FIELDS).map(|i| serde_json::json!(i)).collect();
330		data.insert("name".to_string(), serde_json::json!(large_array));
331
332		let result = validate_mutation_data(&data, &admin, false);
333		assert!(result.is_err());
334		let err = result.unwrap_err();
335		assert!(matches!(err, AdminError::ValidationError(_)));
336		assert!(err.to_string().contains("array too large"));
337	}
338
339	#[rstest]
340	fn test_validate_uses_list_display_as_fallback() {
341		// Admin with no fields() configured, should use list_display()
342		let admin = ModelAdminConfig::builder()
343			.model_name("TestModel")
344			.list_display(vec!["id", "title"])
345			.build()
346			.unwrap();
347
348		let mut data = HashMap::new();
349		data.insert("title".to_string(), serde_json::json!("Test"));
350
351		assert!(validate_mutation_data(&data, &admin, false).is_ok());
352	}
353
354	// ==================== Boundary value: field count ====================
355
356	#[rstest]
357	#[case::below_limit(99, true)]
358	#[case::at_limit(100, true)]
359	#[case::above_limit(101, false)]
360	fn test_mutation_field_count_boundary(#[case] field_count: usize, #[case] should_pass: bool) {
361		// Arrange
362		// Use an admin that allows any field via list_display fallback
363		let field_names: Vec<String> = (0..field_count).map(|i| format!("f_{}", i)).collect();
364		let field_refs: Vec<&str> = field_names.iter().map(|s| s.as_str()).collect();
365		let admin = ModelAdminConfig::builder()
366			.model_name("TestModel")
367			.list_display(field_refs.clone())
368			.fields(field_refs)
369			.build()
370			.unwrap();
371
372		let mut data = HashMap::new();
373		for i in 0..field_count {
374			data.insert(format!("f_{}", i), serde_json::json!("v"));
375		}
376
377		// Act
378		let result = validate_mutation_data(&data, &admin, false);
379
380		// Assert
381		assert_eq!(
382			result.is_ok(),
383			should_pass,
384			"field_count={}, expected pass={}, got {:?}",
385			field_count,
386			should_pass,
387			result
388		);
389	}
390
391	// ==================== Boundary value: string length ====================
392
393	#[rstest]
394	#[case::within_limit(999_999, true)]
395	#[case::at_limit(1_000_000, true)]
396	#[case::above_limit(1_000_001, false)]
397	fn test_mutation_string_length_boundary(#[case] length: usize, #[case] should_pass: bool) {
398		// Arrange
399		let admin = create_test_admin();
400		let mut data = HashMap::new();
401		data.insert("name".to_string(), serde_json::json!("x".repeat(length)));
402
403		// Act
404		let result = validate_mutation_data(&data, &admin, false);
405
406		// Assert
407		assert_eq!(
408			result.is_ok(),
409			should_pass,
410			"length={}, expected pass={}, got {:?}",
411			length,
412			should_pass,
413			result
414		);
415	}
416
417	// ==================== Decision table: mutation validation ====================
418
419	#[rstest]
420	#[case::field_in_allowlist_not_readonly_create(true, false, false, true, true)]
421	#[case::field_in_allowlist_not_readonly_update(true, false, false, false, true)]
422	#[case::field_not_in_allowlist(false, false, false, true, false)]
423	#[case::field_is_readonly_on_create(true, true, false, true, false)]
424	#[case::field_is_readonly_on_update(true, true, false, false, false)]
425	#[case::pk_field_on_create(true, false, true, true, true)]
426	#[case::pk_field_on_update(true, false, true, false, false)]
427	fn test_mutation_validation_decision_table(
428		#[case] in_allowlist: bool,
429		#[case] is_readonly: bool,
430		#[case] is_pk: bool,
431		#[case] is_create: bool,
432		#[case] should_pass: bool,
433	) {
434		// Arrange
435		let field_name = if is_pk { "id" } else { "name" };
436		let is_update = !is_create;
437
438		let mut fields_list = vec!["id"];
439		if in_allowlist && !is_pk {
440			fields_list.push("name");
441		}
442
443		let readonly = if is_readonly && !is_pk {
444			vec!["name"]
445		} else {
446			vec![]
447		};
448
449		let admin = ModelAdminConfig::builder()
450			.model_name("TestModel")
451			.list_display(fields_list.clone())
452			.fields(fields_list)
453			.readonly_fields(readonly)
454			.build()
455			.unwrap();
456
457		let mut data = HashMap::new();
458		data.insert(field_name.to_string(), serde_json::json!("test_value"));
459
460		// Act
461		let result = validate_mutation_data(&data, &admin, is_update);
462
463		// Assert
464		assert_eq!(
465			result.is_ok(),
466			should_pass,
467			"in_allowlist={}, is_readonly={}, is_pk={}, is_create={}, expected pass={}, got {:?}",
468			in_allowlist,
469			is_readonly,
470			is_pk,
471			is_create,
472			should_pass,
473			result
474		);
475	}
476}