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