pub fn read_right_for_resource(resource: Option<&str>) -> String {
resource_right(resource, "read", "gateway.read")
}
pub fn write_right_for_resource(resource: Option<&str>) -> String {
resource_right(resource, "write", "gateway.write")
}
pub fn delete_right_for_resource(resource: Option<&str>) -> String {
resource_right(resource, "delete", "gateway.delete")
}
pub fn query_right() -> String {
"gateway.query".to_string()
}
pub fn storage_proxy_right() -> String {
"gateway.storage_proxy".to_string()
}
pub fn typesense_proxy_right() -> String {
"gateway.typesense_proxy".to_string()
}
pub fn rpc_right() -> String {
"gateway.rpc.execute".to_string()
}
fn resource_right(resource: Option<&str>, action: &str, fallback: &str) -> String {
resource
.and_then(|value| {
if value.contains('.') {
return None;
}
sanitize_identifier(value).map(|sanitized| sanitized.trim_matches('"').to_string())
})
.map(|value| format!("{}.{}", value, action))
.unwrap_or_else(|| fallback.to_string())
}
fn split_right(right: &str) -> Option<(&str, &str)> {
right.split_once('.')
}
pub fn right_matches(granted: &str, required: &str) -> bool {
if granted == "*" || granted == required {
return true;
}
let Some((granted_resource, granted_action)) = split_right(granted) else {
return false;
};
let Some((required_resource, required_action)) = split_right(required) else {
return false;
};
if granted_resource == "*" && granted_action == required_action {
return true;
}
if granted_resource == required_resource && granted_action == "*" {
return true;
}
if granted_resource == "gateway" && granted_action == required_action {
return true;
}
if granted_resource == "gateway" && granted_action == "*" {
return true;
}
false
}
pub fn missing_required_rights(
granted_rights: &[String],
required_rights: &[String],
) -> Vec<String> {
required_rights
.iter()
.filter(|required| {
!granted_rights
.iter()
.any(|granted| right_matches(granted, required))
})
.cloned()
.collect()
}
fn sanitize_identifier(identifier: &str) -> Option<String> {
let mut chars = identifier.chars();
let first = chars.next()?;
if !(first.is_ascii_alphabetic() || first == '_') {
return None;
}
if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
return None;
}
Some(format!("\"{identifier}\""))
}
#[cfg(test)]
mod tests {
use super::{
delete_right_for_resource, missing_required_rights, query_right, read_right_for_resource,
right_matches, rpc_right, storage_proxy_right, typesense_proxy_right,
write_right_for_resource,
};
#[test]
fn wildcard_rights_match_expected_shapes() {
assert!(right_matches("users.read", "users.read"));
assert!(right_matches("users.*", "users.read"));
assert!(right_matches("*.read", "users.read"));
assert!(right_matches("gateway.read", "users.read"));
assert!(right_matches("gateway.*", "users.delete"));
assert!(right_matches("*", "users.read"));
assert!(!right_matches("users.write", "users.read"));
assert!(!right_matches("tickets.read", "users.read"));
}
#[test]
fn resource_right_helpers_fall_back_to_gateway_scopes() {
assert_eq!(read_right_for_resource(Some("users")), "users.read");
assert_eq!(write_right_for_resource(Some("users")), "users.write");
assert_eq!(delete_right_for_resource(Some("users")), "users.delete");
assert_eq!(
read_right_for_resource(Some("public.users")),
"gateway.read"
);
assert_eq!(query_right(), "gateway.query");
assert_eq!(rpc_right(), "gateway.rpc.execute");
assert_eq!(storage_proxy_right(), "gateway.storage_proxy");
assert_eq!(typesense_proxy_right(), "gateway.typesense_proxy");
}
#[test]
fn missing_required_rights_reports_only_uncovered_entries() {
let granted = vec!["users.*".to_string(), "gateway.query".to_string()];
let required = vec![
"users.read".to_string(),
"users.write".to_string(),
"tickets.read".to_string(),
"gateway.query".to_string(),
];
assert_eq!(
missing_required_rights(&granted, &required),
vec!["tickets.read".to_string()]
);
}
}