use indexmap::IndexMap;
use crate::value::{ParseMap, ParseValue};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Operation {
Find,
Count,
Get,
Create,
Update,
Delete,
AddField,
}
impl Operation {
pub fn as_key(self) -> &'static str {
match self {
Operation::Find => "find",
Operation::Count => "count",
Operation::Get => "get",
Operation::Create => "create",
Operation::Update => "update",
Operation::Delete => "delete",
Operation::AddField => "addField",
}
}
pub fn user_fields_key(self) -> UserFieldsKey {
match self {
Operation::Get | Operation::Find | Operation::Count => UserFieldsKey::Read,
_ => UserFieldsKey::Write,
}
}
pub const ALL: [Operation; 7] = [
Operation::Find,
Operation::Count,
Operation::Get,
Operation::Create,
Operation::Update,
Operation::Delete,
Operation::AddField,
];
pub fn from_key(key: &str) -> Option<Operation> {
Operation::ALL.into_iter().find(|op| op.as_key() == key)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserFieldsKey {
Read,
Write,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum OpEntity {
Public,
RequiresAuthentication,
Role(String),
User(String),
}
impl OpEntity {
pub fn parse(key: &str) -> Self {
if key == "*" {
OpEntity::Public
} else if key == "requiresAuthentication" {
OpEntity::RequiresAuthentication
} else if let Some(name) = key.strip_prefix("role:") {
OpEntity::Role(name.to_string())
} else {
OpEntity::User(key.to_string())
}
}
pub fn as_key(&self) -> String {
match self {
OpEntity::Public => "*".to_string(),
OpEntity::RequiresAuthentication => "requiresAuthentication".to_string(),
OpEntity::Role(name) => format!("role:{name}"),
OpEntity::User(id) => id.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PfEntity {
Public,
Authenticated,
UserField(String),
Role(String),
User(String),
}
impl PfEntity {
pub fn parse(key: &str) -> Self {
if key == "*" {
PfEntity::Public
} else if key == "authenticated" {
PfEntity::Authenticated
} else if let Some(field) = key.strip_prefix("userField:") {
PfEntity::UserField(field.to_string())
} else if let Some(name) = key.strip_prefix("role:") {
PfEntity::Role(name.to_string())
} else {
PfEntity::User(key.to_string())
}
}
pub fn as_key(&self) -> String {
match self {
PfEntity::Public => "*".to_string(),
PfEntity::Authenticated => "authenticated".to_string(),
PfEntity::UserField(f) => format!("userField:{f}"),
PfEntity::Role(name) => format!("role:{name}"),
PfEntity::User(id) => id.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpPerm {
pub entities: Vec<OpEntity>,
pub pointer_fields: Vec<String>,
}
impl OpPerm {
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
Self {
entities: Vec::new(),
pointer_fields: Vec::new(),
}
}
pub fn grants(&self, entity: &OpEntity) -> bool {
self.entities.contains(entity)
}
}
#[derive(Debug, Clone)]
pub struct ClassLevelPermissions {
raw: ParseMap,
ops: Vec<(Operation, OpPerm)>,
protected_fields: IndexMap<PfEntity, Vec<String>>,
read_user_fields: Vec<String>,
write_user_fields: Vec<String>,
}
impl ClassLevelPermissions {
pub fn from_map(raw: ParseMap) -> Self {
let mut ops = Vec::new();
for op in Operation::ALL {
match raw.get(op.as_key()) {
Some(ParseValue::Object(entry)) => ops.push((op, parse_op_perm(entry))),
Some(other) if is_js_truthy(other) => ops.push((op, OpPerm::new())),
_ => {}
}
}
let mut protected_fields = IndexMap::new();
if let Some(ParseValue::Object(pf)) = raw.get("protectedFields") {
for (key, value) in pf {
if let ParseValue::Array(items) = value {
protected_fields.insert(PfEntity::parse(key), string_array(items));
}
}
}
Self {
read_user_fields: raw
.get("readUserFields")
.map(string_array_of)
.unwrap_or_default(),
write_user_fields: raw
.get("writeUserFields")
.map(string_array_of)
.unwrap_or_default(),
ops,
protected_fields,
raw,
}
}
pub fn raw(&self) -> &ParseMap {
&self.raw
}
pub fn op(&self, operation: Operation) -> Option<&OpPerm> {
self.ops
.iter()
.find(|(o, _)| *o == operation)
.map(|(_, p)| p)
}
pub fn protected_fields(&self) -> &IndexMap<PfEntity, Vec<String>> {
&self.protected_fields
}
pub fn user_fields(&self, operation: Operation) -> &[String] {
match operation.user_fields_key() {
UserFieldsKey::Read => &self.read_user_fields,
UserFieldsKey::Write => &self.write_user_fields,
}
}
pub fn read_user_fields(&self) -> &[String] {
&self.read_user_fields
}
pub fn write_user_fields(&self) -> &[String] {
&self.write_user_fields
}
pub fn default_acl(&self) -> Option<&ParseValue> {
let acl = self.raw.get("ACL")?;
if !is_js_truthy(acl) || is_the_public_acl(acl) {
return None;
}
Some(acl)
}
pub fn applicable_pointer_fields(&self, operation: Operation) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if let Some(perm) = self.op(operation) {
for f in &perm.pointer_fields {
if !out.contains(f) {
out.push(f.clone());
}
}
}
for f in self.user_fields(operation) {
if !out.contains(f) {
out.push(f.clone());
}
}
out
}
}
fn is_the_public_acl(value: &ParseValue) -> bool {
let ParseValue::Object(entries) = value else {
return false;
};
let mut entries = entries.iter();
let (Some(("*", ParseValue::Object(flags))), None) =
(entries.next().map(|(k, v)| (k.as_str(), v)), entries.next())
else {
return false;
};
let mut flags = flags.iter();
matches!(
(
flags.next().map(|(k, v)| (k.as_str(), v)),
flags.next().map(|(k, v)| (k.as_str(), v)),
flags.next(),
),
(
Some(("read", ParseValue::Bool(true))),
Some(("write", ParseValue::Bool(true))),
None,
)
)
}
pub fn is_js_truthy(value: &ParseValue) -> bool {
match value {
ParseValue::Null => false,
ParseValue::Bool(b) => *b,
ParseValue::Number(n) => *n != 0.0 && !n.is_nan(),
ParseValue::String(s) => !s.is_empty(),
ParseValue::Array(_)
| ParseValue::Object(_)
| ParseValue::Date(_)
| ParseValue::Pointer { .. }
| ParseValue::GeoPoint { .. }
| ParseValue::Bytes(_)
| ParseValue::File { .. }
| ParseValue::Polygon(_)
| ParseValue::Relation { .. } => true,
}
}
fn parse_op_perm(entry: &ParseMap) -> OpPerm {
let mut perm = OpPerm::new();
for (key, value) in entry {
if key == "pointerFields" {
if let ParseValue::Array(items) = value {
perm.pointer_fields = string_array(items);
}
continue;
}
if matches!(value, ParseValue::Bool(true)) {
perm.entities.push(OpEntity::parse(key));
}
}
perm
}
fn string_array(items: &[ParseValue]) -> Vec<String> {
items
.iter()
.filter_map(|v| match v {
ParseValue::String(s) => Some(s.clone()),
_ => None,
})
.collect()
}
fn string_array_of(value: &ParseValue) -> Vec<String> {
match value {
ParseValue::Array(items) => string_array(items),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn clp(json: &str) -> ClassLevelPermissions {
let value = crate::decode::classify(
serde_json::from_str(json).expect("test literal must be valid JSON"),
)
.expect("classify");
match value {
ParseValue::Object(m) => ClassLevelPermissions::from_map(m),
_ => panic!("expected an object"),
}
}
#[test]
fn an_absent_operation_is_unrestricted_not_denied() {
let c = clp(r#"{"find":{"*":true}}"#);
assert!(c.op(Operation::Find).is_some());
assert!(
c.op(Operation::Update).is_none(),
"absent means unrestricted, and the caller must be forced to say so"
);
}
#[test]
fn a_present_but_empty_operation_grants_nobody() {
let c = clp(r#"{"find":{}}"#);
let perm = c.op(Operation::Find).expect("present");
assert!(perm.entities.is_empty());
assert!(!perm.grants(&OpEntity::Public));
}
#[test]
fn a_truthy_non_object_operation_denies_rather_than_being_absent() {
for json in [
r#"{"find":true}"#,
r#"{"find":"x"}"#,
r#"{"find":[]}"#,
r#"{"find":["role:A"]}"#,
r#"{"find":1}"#,
r#"{"find":{"__type":"Date","iso":"2020-01-01T00:00:00.000Z"}}"#,
] {
let c = clp(json);
let perm = c
.op(Operation::Find)
.unwrap_or_else(|| panic!("{json} must be present, not absent"));
assert!(perm.entities.is_empty(), "{json} must grant nobody");
assert!(!perm.grants(&OpEntity::Public), "{json}");
}
}
#[test]
fn a_falsy_operation_value_is_unrestricted_like_an_absent_key() {
for json in [
r#"{"find":false}"#,
r#"{"find":null}"#,
r#"{"find":0}"#,
r#"{"find":""}"#,
] {
assert!(
clp(json).op(Operation::Find).is_none(),
"{json} must read as unrestricted"
);
}
}
#[test]
fn js_truthiness_is_not_rust_emptiness() {
assert!(is_js_truthy(&ParseValue::Array(Vec::new())));
assert!(is_js_truthy(&ParseValue::Bytes(Vec::new())));
assert!(is_js_truthy(&ParseValue::String("0".into())));
assert!(!is_js_truthy(&ParseValue::String(String::new())));
assert!(!is_js_truthy(&ParseValue::Number(0.0)));
assert!(!is_js_truthy(&ParseValue::Number(-0.0)));
assert!(!is_js_truthy(&ParseValue::Number(f64::NAN)));
assert!(is_js_truthy(&ParseValue::Number(-1.0)));
}
#[test]
fn only_literal_true_is_a_grant() {
let c = clp(r#"{"find":{"*":false,"role:A":true,"abc":0,"def":"true"}}"#);
let perm = c.op(Operation::Find).expect("present");
assert_eq!(perm.entities, vec![OpEntity::Role("A".into())]);
}
#[test]
fn pointer_fields_is_not_an_entity() {
let c = clp(r#"{"find":{"pointerFields":["owner"],"*":true}}"#);
let perm = c.op(Operation::Find).expect("present");
assert_eq!(perm.pointer_fields, vec!["owner".to_string()]);
assert_eq!(perm.entities, vec![OpEntity::Public]);
}
#[test]
fn the_two_entity_grammars_do_not_overlap() {
assert_eq!(
OpEntity::parse("requiresAuthentication"),
OpEntity::RequiresAuthentication
);
assert_eq!(
PfEntity::parse("requiresAuthentication"),
PfEntity::User("requiresAuthentication".into())
);
assert_eq!(PfEntity::parse("authenticated"), PfEntity::Authenticated);
assert_eq!(
OpEntity::parse("authenticated"),
OpEntity::User("authenticated".into())
);
assert_eq!(
PfEntity::parse("userField:owner"),
PfEntity::UserField("owner".into())
);
assert_eq!(
OpEntity::parse("userField:owner"),
OpEntity::User("userField:owner".into())
);
}
#[test]
fn a_role_prefixed_key_can_never_parse_as_a_user() {
assert_eq!(
OpEntity::parse("role:Admin"),
OpEntity::Role("Admin".into())
);
assert_eq!(
PfEntity::parse("role:Admin"),
PfEntity::Role("Admin".into())
);
for key in ["role:Admin", "role:", "role:with:colons"] {
assert!(!matches!(OpEntity::parse(key), OpEntity::User(_)), "{key}");
assert!(!matches!(PfEntity::parse(key), PfEntity::User(_)), "{key}");
}
}
#[test]
fn entity_keys_round_trip() {
for key in ["*", "requiresAuthentication", "role:A", "abc123"] {
assert_eq!(OpEntity::parse(key).as_key(), key);
}
for key in ["*", "authenticated", "userField:owner", "role:A", "abc123"] {
assert_eq!(PfEntity::parse(key).as_key(), key);
}
}
#[test]
fn user_fields_split_by_operation_and_add_field_is_a_write() {
let c = clp(r#"{"readUserFields":["r"],"writeUserFields":["w"]}"#);
for op in [Operation::Get, Operation::Find, Operation::Count] {
assert_eq!(c.user_fields(op), ["r".to_string()], "{op:?}");
}
for op in [
Operation::Create,
Operation::Update,
Operation::Delete,
Operation::AddField,
] {
assert_eq!(c.user_fields(op), ["w".to_string()], "{op:?}");
}
}
#[test]
fn applicable_pointer_fields_is_per_op_then_class_wide_deduped() {
let c = clp(r#"{"find":{"pointerFields":["owner","a"]},"readUserFields":["a","b"]}"#);
assert_eq!(
c.applicable_pointer_fields(Operation::Find),
vec!["owner".to_string(), "a".to_string(), "b".to_string()]
);
}
#[test]
fn unmodelled_keys_survive_in_the_raw_block() {
let c = clp(r#"{"find":{"*":true},"someFutureKey":{"x":1}}"#);
assert!(c.raw().contains_key("someFutureKey"));
assert!(c.raw().contains_key("find"));
}
#[test]
fn protected_fields_parse_per_entity() {
let c = clp(r#"{"protectedFields":{"*":["email"],"role:A":["email","phone"]}}"#);
assert_eq!(
c.protected_fields().get(&PfEntity::Public),
Some(&vec!["email".to_string()])
);
assert_eq!(
c.protected_fields()
.get(&PfEntity::Role("A".into()))
.map(Vec::len),
Some(2)
);
}
#[test]
fn a_declared_acl_is_readable_and_an_absent_one_is_none() {
assert!(clp(r#"{"find":{"*":true}}"#).default_acl().is_none());
let c = clp(r#"{"ACL":{"currentUser":{"read":true,"write":true}}}"#);
let ParseValue::Object(acl) = c.default_acl().expect("declared") else {
panic!("expected an object");
};
assert!(acl.contains_key("currentUser"));
}
#[test]
fn a_falsy_declared_acl_is_not_a_default() {
for literal in [
r#"{"ACL":null}"#,
r#"{"ACL":false}"#,
r#"{"ACL":0}"#,
r#"{"ACL":""}"#,
] {
assert!(clp(literal).default_acl().is_none(), "{literal}");
}
}
#[test]
fn the_public_acl_is_not_stamped() {
assert!(clp(r#"{"ACL":{"*":{"read":true,"write":true}}}"#)
.default_acl()
.is_none());
}
#[test]
fn a_reordered_or_extended_public_acl_is_still_stamped() {
for literal in [
r#"{"ACL":{"*":{"write":true,"read":true}}}"#,
r#"{"ACL":{"*":{"read":true,"write":true,"delete":true}}}"#,
r#"{"ACL":{"*":{"read":true}}}"#,
r#"{"ACL":{"*":{"read":true,"write":true},"role:A":{"read":true}}}"#,
r#"{"ACL":{"role:A":{"read":true},"*":{"read":true,"write":true}}}"#,
] {
assert!(
clp(literal).default_acl().is_some(),
"{literal} does not stringify to the public ACL and must be stamped"
);
}
}
}