use parse_rust_core::{
js_number, ClassLevelPermissions, ErrorCode, ParseError, ParseMap, ParseValue,
};
use parse_rust_storage::ClassSchema;
use crate::infer::DEFAULT_COLUMNS;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectIdForm {
Generated,
Custom,
}
impl ObjectIdForm {
fn accepts(self, key: &str) -> bool {
match self {
ObjectIdForm::Generated => {
!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric())
}
ObjectIdForm::Custom => !key.is_empty(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unenforceable {
Refuse,
Accept,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClpValidation {
pub object_id: ObjectIdForm,
pub unenforceable: Unenforceable,
}
pub const VALID_KEYS: [&str; 11] = [
"ACL",
"find",
"count",
"get",
"create",
"update",
"delete",
"addField",
"readUserFields",
"writeUserFields",
"protectedFields",
];
pub fn validate_clp(
raw: ParseMap,
schema: &ClassSchema,
opts: ClpValidation,
) -> Result<ClassLevelPermissions, ParseError> {
for (operation_key, operation) in &raw {
if !VALID_KEYS.contains(&operation_key.as_str()) {
return Err(ParseError::invalid_json(format!(
"{operation_key} is not a valid operation for class level permissions"
)));
}
validate_clp_json(operation, operation_key)?;
if operation_key == "readUserFields" || operation_key == "writeUserFields" {
if opts.unenforceable == Unenforceable::Refuse {
return Err(unenforceable(operation_key));
}
if let ParseValue::Array(items) = operation {
for item in items {
validate_pointer_permission(item, schema, operation_key)?;
}
}
continue;
}
if operation_key == "protectedFields" {
let entries = js_own_entries(operation);
for (entity, protected) in &entries {
let (entity, protected) = (entity.as_str(), protected);
validate_protected_fields_key(entity, opts.object_id)?;
if opts.unenforceable == Unenforceable::Refuse && entity.starts_with("userField:") {
return Err(unenforceable(entity));
}
let ParseValue::Array(fields) = protected else {
return Err(ParseError::invalid_json(format!(
"'{}' is not a valid value for protectedFields[{entity}] - expected an \
array.",
js_string(protected)
)));
};
for field in fields {
let name = js_string(field);
if DEFAULT_COLUMNS.iter().any(|(n, _)| *n == name) {
return Err(ParseError::invalid_json(format!(
"Default field '{name}' can not be protected"
)));
}
if !schema.fields.contains_key(&name) {
return Err(ParseError::invalid_json(format!(
"Field '{name}' in protectedFields:{entity} does not exist"
)));
}
}
}
continue;
}
let entries = js_own_entries(operation);
for (entity, permit) in &entries {
let (entity, permit) = (entity.as_str(), permit);
validate_permission_key(entity, opts.object_id)?;
if entity == "pointerFields" {
let ParseValue::Array(pointer_fields) = permit else {
return Err(ParseError::invalid_json(format!(
"'{}' is not a valid value for {operation_key}[{entity}] - expected an \
array.",
js_string(permit)
)));
};
for pointer_field in pointer_fields {
validate_pointer_permission(pointer_field, schema, "[object Object]")?;
}
continue;
}
if operation_key == "ACL" {
validate_clp_acl_entry(permit)?;
} else if !matches!(permit, ParseValue::Bool(true)) {
return Err(ParseError::invalid_json(format!(
"'{}' is not a valid value for class level permissions acl \
{operation_key}:{entity}",
js_string(permit)
)));
}
}
}
Ok(ClassLevelPermissions::from_map(raw))
}
fn unenforceable(key: &str) -> ParseError {
ParseError::new(
ErrorCode::CommandUnavailable,
format!(
"{key} is not supported yet. parse-rust validates it and cannot enforce it, so the \
class level permissions are refused rather than stored unenforced."
),
)
}
fn validate_clp_json(operation: &ParseValue, operation_key: &str) -> Result<(), ParseError> {
if operation_key == "readUserFields" || operation_key == "writeUserFields" {
if !matches!(operation, ParseValue::Array(_)) {
return Err(ParseError::invalid_json(format!(
"'{}' is not a valid value for class level permissions {operation_key} - must be \
an array",
js_string(operation)
)));
}
return Ok(());
}
if is_js_object(operation) {
return Ok(());
}
Err(ParseError::invalid_json(format!(
"'{}' is not a valid value for class level permissions {operation_key} - must be an object",
js_string(operation)
)))
}
fn validate_permission_key(key: &str, object_id: ObjectIdForm) -> Result<(), ParseError> {
let matches_some = key == "pointerFields"
|| key == "*"
|| key == "requiresAuthentication"
|| key.starts_with("role:");
if matches_some || object_id.accepts(key) {
return Ok(());
}
Err(invalid_clp_key(key))
}
fn validate_protected_fields_key(key: &str, object_id: ObjectIdForm) -> Result<(), ParseError> {
let matches_some = key.starts_with("userField:")
|| key == "*"
|| key == "authenticated"
|| key.starts_with("role:");
if matches_some || object_id.accepts(key) {
return Ok(());
}
Err(invalid_clp_key(key))
}
fn invalid_clp_key(key: &str) -> ParseError {
ParseError::invalid_json(format!(
"'{key}' is not a valid key for class level permissions"
))
}
fn validate_pointer_permission(
field: &ParseValue,
schema: &ClassSchema,
operation: &str,
) -> Result<(), ParseError> {
let name = js_string(field);
let ok = match schema.fields.get(&name) {
Some(ty) => {
ty.target_class() == Some("_User") && ty.is_pointer()
|| matches!(ty, parse_rust_storage::FieldType::Array)
}
None => false,
};
if ok {
return Ok(());
}
Err(ParseError::invalid_json(format!(
"'{name}' is not a valid column for class level pointer permissions {operation}"
)))
}
fn validate_clp_acl_entry(permit: &ParseValue) -> Result<(), ParseError> {
let ParseValue::Object(entry) = permit else {
return Err(ParseError::invalid_json(format!(
"'{}' is not a valid value for class level permissions acl",
js_string(permit)
)));
};
let invalid_keys: Vec<&str> = entry
.keys()
.filter(|k| k.as_str() != "read" && k.as_str() != "write")
.map(String::as_str)
.collect();
if !invalid_keys.is_empty() {
return Err(ParseError::invalid_json(format!(
"'{}' is not a valid key for class level permissions acl",
invalid_keys.join(",")
)));
}
let invalid_values: Vec<String> = entry
.values()
.filter(|v| !matches!(v, ParseValue::Bool(_)))
.map(js_string)
.collect();
if !invalid_values.is_empty() {
return Err(ParseError::invalid_json(format!(
"'{}' is not a valid value for class level permissions acl",
invalid_values.join(",")
)));
}
Ok(())
}
fn js_own_entries(value: &ParseValue) -> Vec<(String, ParseValue)> {
let tagged = |pairs: Vec<(&str, ParseValue)>| {
pairs
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect::<Vec<_>>()
};
let s = |v: &str| ParseValue::String(v.to_string());
match value {
ParseValue::Object(map) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
ParseValue::Array(items) => items
.iter()
.enumerate()
.map(|(i, v)| (i.to_string(), v.clone()))
.collect(),
ParseValue::String(text) => text
.chars()
.enumerate()
.map(|(i, c)| (i.to_string(), ParseValue::String(c.to_string())))
.collect(),
ParseValue::Date(d) => tagged(vec![("__type", s("Date")), ("iso", s(&d.to_iso()))]),
ParseValue::Pointer {
class_name,
object_id,
} => tagged(vec![
("__type", s("Pointer")),
("className", s(class_name)),
("objectId", s(object_id)),
]),
ParseValue::GeoPoint {
latitude,
longitude,
} => tagged(vec![
("__type", s("GeoPoint")),
("latitude", ParseValue::Number(*latitude)),
("longitude", ParseValue::Number(*longitude)),
]),
ParseValue::Bytes(_) => tagged(vec![("__type", s("Bytes")), ("base64", s(""))]),
ParseValue::File { name, .. } => tagged(vec![("__type", s("File")), ("name", s(name))]),
ParseValue::Polygon(_) => tagged(vec![
("__type", s("Polygon")),
("coordinates", ParseValue::Array(Vec::new())),
]),
ParseValue::Relation { class_name } => tagged(vec![
("__type", s("Relation")),
("className", s(class_name)),
]),
ParseValue::Null | ParseValue::Bool(_) | ParseValue::Number(_) => Vec::new(),
}
}
fn is_js_object(value: &ParseValue) -> bool {
!matches!(
value,
ParseValue::Null | ParseValue::Bool(_) | ParseValue::Number(_) | ParseValue::String(_)
)
}
fn js_string(value: &ParseValue) -> String {
match value {
ParseValue::Null => "null".to_string(),
ParseValue::Bool(b) => b.to_string(),
ParseValue::Number(n) => js_number::to_ecma_string(*n),
ParseValue::String(s) => s.clone(),
ParseValue::Array(items) => items
.iter()
.map(|item| match item {
ParseValue::Null => String::new(),
other => js_string(other),
})
.collect::<Vec<_>>()
.join(","),
_ => "[object Object]".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use parse_rust_core::{classify, OpEntity, Operation, PfEntity};
use parse_rust_storage::FieldType;
fn opts() -> ClpValidation {
ClpValidation {
object_id: ObjectIdForm::Generated,
unenforceable: Unenforceable::Accept,
}
}
fn map(json: &str) -> ParseMap {
match classify(serde_json::from_str(json).expect("test literal must be valid JSON"))
.expect("classify")
{
ParseValue::Object(m) => m,
other => panic!("expected an object, got {other:?}"),
}
}
fn schema() -> ClassSchema {
crate::controller::default_schema("Post")
.with_field("title", FieldType::String)
.with_field(
"owner",
FieldType::Pointer {
target_class: "_User".into(),
},
)
.with_field(
"author",
FieldType::Pointer {
target_class: "Writer".into(),
},
)
.with_field("editors", FieldType::Array)
}
fn err(json: &str) -> ParseError {
validate_clp(map(json), &schema(), opts()).expect_err("should be rejected")
}
fn ok(json: &str) -> ClassLevelPermissions {
validate_clp(map(json), &schema(), opts()).expect("should be accepted")
}
#[test]
fn an_unknown_top_level_key_is_refused_without_quotes() {
let e = err(r#"{"nope":{"*":true}}"#);
assert_eq!(
e.message,
"nope is not a valid operation for class level permissions"
);
assert_eq!(e.code, ErrorCode::InvalidJson);
}
#[test]
fn every_valid_top_level_key_is_accepted() {
assert_eq!(VALID_KEYS.len(), 11);
ok(r#"{
"ACL":{"*":{"read":true,"write":true}},
"find":{"*":true},"count":{"*":true},"get":{"*":true},
"create":{"*":true},"update":{"*":true},"delete":{"*":true},
"addField":{"*":true},
"readUserFields":["owner"],"writeUserFields":["owner"],
"protectedFields":{"*":["title"]}
}"#);
}
#[test]
fn only_literal_true_grants_an_operation() {
for (json, rendered) in [
(r#"{"find":{"*":false}}"#, "false"),
(r#"{"find":{"*":0}}"#, "0"),
(r#"{"find":{"*":"true"}}"#, "true"),
(r#"{"find":{"*":null}}"#, "null"),
(r#"{"find":{"*":1}}"#, "1"),
] {
let e = err(json);
assert_eq!(
e.message,
format!("'{rendered}' is not a valid value for class level permissions acl find:*"),
"{json}"
);
}
}
#[test]
fn numbers_render_through_the_ecmascript_algorithm() {
assert_eq!(js_string(&ParseValue::Number(1.0)), "1");
assert_eq!(js_string(&ParseValue::Number(1.5)), "1.5");
assert_eq!(js_string(&ParseValue::Number(-0.0)), "0");
}
#[test]
fn a_non_object_operation_is_refused_before_its_entities() {
let e = err(r#"{"find":true}"#);
assert_eq!(
e.message,
"'true' is not a valid value for class level permissions find - must be an object"
);
let e = err(r#"{"readUserFields":"owner"}"#);
assert_eq!(
e.message,
"'owner' is not a valid value for class level permissions readUserFields - must be an \
array"
);
}
#[test]
fn the_two_entity_grammars_reject_each_others_spellings() {
let e = err(r#"{"find":{"has-dash":true}}"#);
assert_eq!(
e.message,
"'has-dash' is not a valid key for class level permissions"
);
let e = err(r#"{"protectedFields":{"has-dash":["title"]}}"#);
assert_eq!(
e.message,
"'has-dash' is not a valid key for class level permissions"
);
}
#[test]
fn a_custom_object_id_configuration_widens_the_entity_grammar() {
let custom = ClpValidation {
object_id: ObjectIdForm::Custom,
unenforceable: Unenforceable::Accept,
};
assert!(validate_clp(map(r#"{"find":{"has-dash":true}}"#), &schema(), opts()).is_err());
assert!(validate_clp(map(r#"{"find":{"has-dash":true}}"#), &schema(), custom).is_ok());
assert!(validate_clp(map(r#"{"find":{"":true}}"#), &schema(), custom).is_err());
}
#[test]
fn an_empty_role_name_is_accepted() {
let c = ok(r#"{"find":{"role:":true}}"#);
let perm = c.op(Operation::Find).expect("find is present");
assert_eq!(perm.entities, vec![OpEntity::Role(String::new())]);
}
#[test]
fn pointer_fields_must_be_an_array_of_user_pointers_or_arrays() {
ok(r#"{"find":{"pointerFields":["owner"]}}"#);
ok(r#"{"find":{"pointerFields":["editors"]}}"#);
let e = err(r#"{"find":{"pointerFields":["author"]}}"#);
assert_eq!(
e.message,
"'author' is not a valid column for class level pointer permissions [object Object]"
);
assert!(err(r#"{"find":{"pointerFields":["title"]}}"#)
.message
.starts_with("'title' is not a valid column"));
assert!(err(r#"{"find":{"pointerFields":["ghost"]}}"#)
.message
.starts_with("'ghost' is not a valid column"));
}
#[test]
fn the_pointer_permission_message_differs_between_the_two_call_sites() {
assert_eq!(
err(r#"{"readUserFields":["title"]}"#).message,
"'title' is not a valid column for class level pointer permissions readUserFields"
);
assert_eq!(
err(r#"{"writeUserFields":["title"]}"#).message,
"'title' is not a valid column for class level pointer permissions writeUserFields"
);
assert_eq!(
err(r#"{"find":{"pointerFields":["title"]}}"#).message,
"'title' is not a valid column for class level pointer permissions [object Object]"
);
}
#[test]
fn a_non_array_pointer_fields_names_the_operation_and_the_entity() {
let e = err(r#"{"update":{"pointerFields":"owner"}}"#);
assert_eq!(
e.message,
"'owner' is not a valid value for update[pointerFields] - expected an array."
);
}
#[test]
fn protected_fields_must_be_arrays_of_existing_non_default_fields() {
let c = ok(r#"{"protectedFields":{"*":["title"],"role:A":["title","owner"]}}"#);
assert_eq!(
c.protected_fields().get(&PfEntity::Public),
Some(&vec!["title".to_string()])
);
let e = err(r#"{"protectedFields":{"*":"title"}}"#);
assert_eq!(
e.message,
"'title' is not a valid value for protectedFields[*] - expected an array."
);
let e = err(r#"{"protectedFields":{"role:A":["ghost"]}}"#);
assert_eq!(
e.message,
"Field 'ghost' in protectedFields:role:A does not exist"
);
}
#[test]
fn no_default_column_can_be_protected() {
for column in ["objectId", "createdAt", "updatedAt", "ACL"] {
let e = err(&format!(r#"{{"protectedFields":{{"*":["{column}"]}}}}"#));
assert_eq!(
e.message,
format!("Default field '{column}' can not be protected")
);
}
}
#[test]
fn the_clp_acl_key_takes_read_and_write_booleans() {
ok(r#"{"ACL":{"*":{"read":true,"write":false}}}"#);
let e = err(r#"{"ACL":{"*":true}}"#);
assert_eq!(
e.message,
"'true' is not a valid value for class level permissions acl"
);
let e = err(r#"{"ACL":{"*":{"read":true,"delete":true,"update":true}}}"#);
assert_eq!(
e.message,
"'delete,update' is not a valid key for class level permissions acl"
);
let e = err(r#"{"ACL":{"*":{"read":1,"write":"yes"}}}"#);
assert_eq!(
e.message,
"'1,yes' is not a valid value for class level permissions acl"
);
}
#[test]
fn unenforceable_features_are_refused_not_ignored() {
let refuse = ClpValidation {
object_id: ObjectIdForm::Generated,
unenforceable: Unenforceable::Refuse,
};
for json in [
r#"{"readUserFields":["owner"]}"#,
r#"{"writeUserFields":["owner"]}"#,
r#"{"protectedFields":{"userField:owner":["title"]}}"#,
] {
let e = validate_clp(map(json), &schema(), refuse).expect_err("must be refused");
assert_eq!(e.code, ErrorCode::CommandUnavailable, "{json}");
}
for json in [
r#"{"readUserFields":["owner"]}"#,
r#"{"writeUserFields":["owner"]}"#,
r#"{"protectedFields":{"userField:owner":["title"]}}"#,
] {
assert!(validate_clp(map(json), &schema(), opts()).is_ok(), "{json}");
}
}
#[test]
fn refusing_user_field_entries_does_not_refuse_ordinary_ones() {
let refuse = ClpValidation {
object_id: ObjectIdForm::Generated,
unenforceable: Unenforceable::Refuse,
};
assert!(validate_clp(
map(r#"{"protectedFields":{"*":["title"],"role:A":["title"],"authenticated":["title"]}}"#),
&schema(),
refuse
)
.is_ok());
}
#[test]
fn the_raw_block_survives_validation_unchanged() {
let c = ok(r#"{"find":{"*":true},"ACL":{"*":{"read":true}}}"#);
assert!(c.raw().contains_key("ACL"));
assert!(c.raw().contains_key("find"));
assert_eq!(c.raw().len(), 2);
}
#[test]
fn an_empty_block_validates() {
let c = ok("{}");
assert!(c.op(Operation::Find).is_none());
assert!(c.raw().is_empty());
}
#[test]
fn an_array_operation_value_is_refused() {
let err = validate_clp(map(r#"{"find": ["*"]}"#), &schema(), opts())
.expect_err("an array is not a permission object");
assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson);
}
#[test]
fn a_tagged_operation_value_is_refused_rather_than_silently_locking_the_class() {
let err = validate_clp(
map(r#"{"find": {"__type": "Date", "iso": "2026-01-01T00:00:00.000Z"}}"#),
&schema(),
opts(),
)
.expect_err("a Date is not a permission object");
assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson);
}
#[test]
fn an_array_protected_fields_value_is_refused() {
let err = validate_clp(map(r#"{"protectedFields": ["title"]}"#), &schema(), opts())
.expect_err("an array is not a protectedFields object");
assert_eq!(err.code, parse_rust_core::ErrorCode::InvalidJson);
}
#[test]
fn a_primitive_operation_value_yields_no_entries() {
assert!(js_own_entries(&ParseValue::Number(1.0)).is_empty());
assert!(js_own_entries(&ParseValue::Bool(true)).is_empty());
assert!(js_own_entries(&ParseValue::Null).is_empty());
}
}