use std::sync::Arc;
use wresp::RespCommand;
use super::{
AclPassword, RespAclCategories, access_control_list::AccessControlList, acl_exception::AclError,
command_catalog as catalog, user::User, user_handle::UserHandle,
};
const CATEGORY_NAMES: [(&str, RespAclCategories); 25] = [
("admin", RespAclCategories::ADMIN),
("bitmap", RespAclCategories::BITMAP),
("blocking", RespAclCategories::BLOCKING),
("connection", RespAclCategories::CONNECTION),
("dangerous", RespAclCategories::DANGEROUS),
("geo", RespAclCategories::GEO),
("hash", RespAclCategories::HASH),
("hyperloglog", RespAclCategories::HYPERLOGLOG),
("fast", RespAclCategories::FAST),
("keyspace", RespAclCategories::KEYSPACE),
("list", RespAclCategories::LIST),
("pubsub", RespAclCategories::PUBSUB),
("read", RespAclCategories::READ),
("scripting", RespAclCategories::SCRIPTING),
("set", RespAclCategories::SET),
("sortedset", RespAclCategories::SORTEDSET),
("slow", RespAclCategories::SLOW),
("stream", RespAclCategories::STREAM),
("string", RespAclCategories::STRING),
("transaction", RespAclCategories::TRANSACTION),
("vector", RespAclCategories::VECTOR),
("write", RespAclCategories::WRITE),
("garnet", RespAclCategories::GARNET),
("custom", RespAclCategories::CUSTOM),
("all", RespAclCategories::ALL),
];
pub struct AclParser;
impl AclParser {
pub fn parse_acl_rule(
input: &str,
acl: Option<&AccessControlList>,
) -> Result<Arc<User>, AclError> {
let tokens: Vec<&str> = input.split_whitespace().collect();
if tokens.len() < 3 {
return Err(Self::parsing_err("Malformed ACL rule"));
}
if !tokens[0].eq_ignore_ascii_case("user") {
return Err(Self::parsing_err(
"ACL rules need to start with the USER keyword",
));
}
let username = tokens[1];
let user = match acl.and_then(|acl| acl.get_user_handle(username)) {
Some(handle) => handle.user(),
None => {
let user = Arc::new(User::new(username.to_string()));
if let Some(acl) = acl {
acl.add_user_handle(Arc::new(UserHandle::new(Arc::clone(&user))))?;
}
user
}
};
for op in &tokens[2..] {
Self::apply_acl_op_to_user(&user, op)?;
}
Ok(user)
}
#[inline]
fn parsing_err(message: &str) -> AclError {
AclError::Parsing {
message: message.into(),
filename: String::new(),
line: -1,
}
}
pub fn apply_acl_op_to_user(user: &User, op: &str) -> Result<(), AclError> {
if op.is_empty() {
return Ok(());
}
let first = op.as_bytes()[0];
match first {
_ if op.eq_ignore_ascii_case("on") => user.set_enabled(true),
_ if op.eq_ignore_ascii_case("off") => user.set_enabled(false),
_ if op.eq_ignore_ascii_case("nopass") => {
user.clear_passwords();
user.set_passwordless(true);
}
_ if op.eq_ignore_ascii_case("reset") => user.reset(),
_ if op.eq_ignore_ascii_case("resetpass") => {
user.clear_passwords();
user.set_passwordless(false);
}
b'>' => user.add_password_hash(AclPassword::from_string(&op[1..])),
b'<' => user.remove_password_hash(AclPassword::from_string(&op[1..])),
b'#' | b'!' => {
let hash = AclPassword::from_hash(&op[1..]).map_err(|e| AclError::Parsing {
message: e.to_string(),
filename: String::new(),
line: -1,
})?;
if first == b'#' {
user.add_password_hash(hash);
} else {
user.remove_password_hash(hash);
}
}
b'-' | b'+' if op.len() >= 2 && op.as_bytes()[1] == b'@' => {
let category_name = &op[2..];
let category = Self::get_acl_category_by_name(category_name)
.ok_or_else(|| AclError::CategoryDoesNotExist(category_name.to_string()))?;
if first == b'-' {
user.remove_category(category)?;
} else {
user.add_category(category)?;
}
}
b'-' | b'+' => {
let command_name = &op[1..];
match Self::try_parse_command_for_acl(command_name) {
Some(command) => {
if first == b'-' {
user.remove_command(command)?;
} else {
user.add_command(command)?;
}
}
None if Self::is_valid_custom_command_name(command_name) => {
if first == b'-' {
user.remove_custom_command(command_name)?;
} else {
user.add_custom_command(command_name)?;
}
}
None => return Err(AclError::CommandDoesNotExist(command_name.to_string())),
}
}
_ if op == "~*" || op.eq_ignore_ascii_case("allkeys") => {}
_ if op.eq_ignore_ascii_case("resetkeys") => {}
_ => return Err(AclError::UnknownOperation(op.to_string())),
}
Ok(())
}
pub fn try_parse_command_for_acl(command_name: &str) -> Option<RespCommand> {
let sep_ix = command_name.find('|');
let (effective, is_sub_command) = match sep_ix {
Some(ix) => (
format!("{}_{}", &command_name[..ix], &command_name[ix + 1..]),
true,
),
None => (command_name.to_string(), false),
};
let command = Self::lookup_command(&effective)
.or_else(|| {
effective.contains('.').then(|| {
let dotless: String = effective.chars().filter(|&c| c != '.').collect();
Self::lookup_command(&dotless)
})?
})
.or_else(|| {
command_name
.eq_ignore_ascii_case("SLAVEOF")
.then_some(RespCommand::Secondaryof)
})
.or_else(|| {
command_name
.eq_ignore_ascii_case("CLUSTER|SET-CONFIG-EPOCH")
.then_some(RespCommand::ClusterSetconfigepoch)
})?;
if is_sub_command && catalog::try_get_resp_command_info(command).is_none() {
return None;
}
(!Self::is_invalid_command_to_acl(command)).then_some(command)
}
#[inline]
fn lookup_command(effective_name: &str) -> Option<RespCommand> {
let entry = catalog::try_get_by_cs_name(effective_name)?;
Self::is_valid_parse(entry.cmd, effective_name).then_some(entry.cmd)
}
#[inline]
pub fn is_valid_parse(command: RespCommand, from_str: &str) -> bool {
command != RespCommand::None
&& command != RespCommand::Invalid
&& !from_str.bytes().any(|b| b.is_ascii_digit())
}
#[inline]
pub fn is_invalid_command_to_acl(command: RespCommand) -> bool {
command == RespCommand::Invalid
|| command == RespCommand::None
|| catalog::normalize_for_acls(command) != command
}
pub fn is_valid_custom_command_name(name: &str) -> bool {
let bytes = name.as_bytes();
match bytes.split_first() {
None => false,
Some((&first, rest)) => {
first.is_ascii_alphanumeric()
&& rest
.iter()
.all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'|'))
}
}
}
pub fn get_acl_category_by_name(category_name: &str) -> Option<RespAclCategories> {
CATEGORY_NAMES
.iter()
.find(|e| e.0.eq_ignore_ascii_case(category_name))
.map(|&(_, cat)| cat)
}
pub fn get_name_by_acl_category(category: RespAclCategories) -> &'static str {
CATEGORY_NAMES
.iter()
.find(|e| e.1 == category)
.map_or("unknown", |&(name, _)| name)
}
pub fn list_categories() -> &'static [&'static str] {
const NAMES: [&str; CATEGORY_NAMES.len()] = [
"admin",
"bitmap",
"blocking",
"connection",
"dangerous",
"geo",
"hash",
"hyperloglog",
"fast",
"keyspace",
"list",
"pubsub",
"read",
"scripting",
"set",
"sortedset",
"slow",
"stream",
"string",
"transaction",
"vector",
"write",
"garnet",
"custom",
"all",
];
&NAMES
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::access_control_list::AccessControlList;
#[test]
fn parse_acl_rule_description() {
const CASES: &[(&str, &str)] = &[
("user 1-command on +set", "+set"),
("user 2-command on +set +get", "+set +get"),
("user 3-command-duplicates-reduce on +set +set", "+set"),
(
"user 4-command-duplicates-complicated on +set +set -set +set",
"+set",
),
(
"user 5-command-duplicates-complicated on +get -set +set",
"+get +set",
),
("user 6-category on +@keyspace", "+@keyspace"),
("user 7-category-reduces on +@all", "+@all"),
("user 7-category-reduces on -@all", ""),
("user 8-category-reduces on -@all +@keyspace", "+@keyspace"),
("user 9-category-reduces on +@all +@keyspace", "+@all"),
(
"user 10-category-command-reduces on +@keyspace +del",
"+@keyspace",
),
(
"user 11-category-command-reduces on +@keyspace +set",
"+@keyspace +set",
),
(
"user 12-category-command-reduces on +@keyspace +del -del",
"+@keyspace -del",
),
("user 13-category-command-reduces on +del -@keyspace", ""),
(
"user 14-category-command-reduces on -del +@keyspace",
"+@keyspace",
),
(
"user 15-category-command-reduces on +set +@keyspace",
"+set +@keyspace",
),
("user 16-category-command-reduces on +@all +set", "+@all"),
(
"user 17-category-command-reduces on +@all +set +get +incr -decr",
"+@all -decr",
),
("user 18-category-command-reduces on -@all +set", "+set"),
(
"user 19-category-command-reduces on -@all +set +get",
"+set +get",
),
(
"user 20-category-command-reduces on -@all +set +get +incr +decr +incrby +decrby",
"+set +get +incr +decr +incrby +decrby",
),
(
"user 21-category-command-reduces on -@all +ping +auth +set +get +del +incr +decr +incrby +decrby +expire +ttl +keys +scan +hget",
"+ping +auth +set +get +del +incr +decr +incrby +decrby +expire +ttl +keys +scan +hget",
),
(
"user 22-category-command-reduces on -@all +ping +auth +set +get +del +incr +decr +incrby +decrby +expire +ttl +keys +scan +hget +config|get",
"+ping +auth +set +get +del +incr +decr +incrby +decrby +expire +ttl +keys +scan +hget +config|get",
),
(
"user 23-category-command-reduces on -@all +set +get +incr +decr +@keyspace +@hash +incrby +decrby",
"+set +get +incr +decr +@keyspace +@hash +incrby +decrby",
),
(
"user 24-multi-category-reduces on -@all +@keyspace +@hash",
"+@keyspace +@hash",
),
(
"user 25-multi-category-reduces on -@all +@keyspace +@hash -flushdb",
"+@keyspace +@hash -flushdb",
),
(
"user 26-multi-category-reduces on -@all +@keyspace -flushdb +@hash -flushdb",
"+@keyspace -flushdb +@hash",
),
(
"user 27-multi-category-reduces on -@all +set +get +incr +decr +@keyspace +@hash +incrby +decrby +script|exists +@pubsub +expire +ttl",
"+set +get +incr +decr +@keyspace +@hash +incrby +decrby +script|exists +@pubsub",
),
("user 28-command-reversed-duplicates on -set +set", "+set"),
];
for &(rule, expected) in CASES {
let user = AclParser::parse_acl_rule(rule, None).unwrap_or_else(|e| panic!("{rule}: {e}"));
assert_eq!(
user.get_enabled_commands_description(),
expected,
"rule: {rule}"
);
}
}
#[test]
fn parse_acl_rule_description_timeouts() {
const CASES: &[(&str, &str)] = &[
(
"user 1-command-notimeout on +auth +ping +get +set +del +exists +incr +decr +mget +mset +expire +ttl +keys +scan +hget +hset +lpush +rpush +sadd +decrby",
"+auth +ping +get +set +del +exists +incr +decr +mget +mset +expire +ttl +keys +scan +hget +hset +lpush +rpush +sadd +decrby",
),
(
"user 2-category-command-notimeout on -@all +ping +auth +set +get +del +incr +decr +incrby +decrby +expire +ttl +keys +scan +hget +mget +mset +eval +evalsha +setex",
"+ping +auth +set +get +del +incr +decr +incrby +decrby +expire +ttl +keys +scan +hget +mget +mset +eval +evalsha +setex",
),
(
"user 3-category-command-notimeout on -@all +client|id +client|info +cluster|nodes +cluster|slots +echo +info +ping +config|get +decr -decr +decrby +del +expire +flushdb +get +incr +incrby +latency +eval +evalsha +script|exists +script|flush +script|load +set +setex +unlink",
"+client|id +client|info +cluster|nodes +cluster|slots +echo +info +ping +config|get +decr -decr +decrby +del +expire +flushdb +get +incr +incrby +latency +eval +evalsha +script|exists +script|flush +script|load +set +setex +unlink",
),
(
"user 4-category-command-notimeout on +@keyspace +client|id +client|info +cluster|nodes +cluster|slots +echo +info +ping +config|get +decr -decr +decrby +del +expire +flushdb +get +incr +incrby +latency +eval +evalsha +script|exists +script|flush +script|load +set +setex +unlink",
"+@keyspace +client|id +client|info +cluster|nodes +cluster|slots +echo +info +ping +config|get +decr -decr +decrby +get +incr +incrby +latency +eval +evalsha +script|exists +script|flush +script|load +set +setex",
),
(
"user 5-category-command-notimeout on -@all +@keyspace +client|id +client|info +cluster|nodes +cluster|slots +echo +info +ping +config|get +decr -decr +decrby +del +expire +flushdb +get +incr +incrby +latency +eval +evalsha +script|exists +script|flush +script|load +set +setex +unlink",
"+@keyspace +client|id +client|info +cluster|nodes +cluster|slots +echo +info +ping +config|get +decr -decr +decrby +get +incr +incrby +latency +eval +evalsha +script|exists +script|flush +script|load +set +setex",
),
];
for &(rule, expected) in CASES {
let user = AclParser::parse_acl_rule(rule, None).unwrap_or_else(|e| panic!("{rule}: {e}"));
assert_eq!(
user.get_enabled_commands_description(),
expected,
"rule: {rule}"
);
}
}
#[test]
fn parse_acl_rule_malformed() {
assert!(matches!(
AclParser::parse_acl_rule("user x", None),
Err(AclError::Parsing { .. })
));
assert!(matches!(
AclParser::parse_acl_rule("usr x on", None),
Err(AclError::Parsing { .. })
));
assert!(matches!(
AclParser::parse_acl_rule("user x on whatsthis", None),
Err(AclError::UnknownOperation(op)) if op == "whatsthis"
));
assert!(matches!(
AclParser::parse_acl_rule("user x on +@nosuch", None),
Err(AclError::CategoryDoesNotExist(c)) if c == "nosuch"
));
assert!(matches!(
AclParser::parse_acl_rule("user x on +bad!name", None),
Err(AclError::CommandDoesNotExist(c)) if c == "bad!name"
));
}
#[test]
fn parse_acl_rule_password_ops() {
const HASH: &str = "8f0e2f76e22b43e2855189877e7dc1e1e7d98c226c95db247cd1d547928334a9";
let user = AclParser::parse_acl_rule(&format!("user x on >passw0rd #{HASH}"), None).unwrap();
assert!(user.validate_password(&AclPassword::from_string("passw0rd")));
let user = AclParser::parse_acl_rule("user x on >passw0rd <passw0rd", None).unwrap();
assert!(!user.validate_password(&AclPassword::from_string("passw0rd")));
let user = AclParser::parse_acl_rule(&format!("user x on #{HASH} !{HASH}"), None).unwrap();
assert!(!user.validate_password(&AclPassword::from_string("passw0rd")));
assert!(matches!(
AclParser::parse_acl_rule("user x on #deadbeef", None),
Err(AclError::Parsing { .. })
));
let user = AclParser::parse_acl_rule("user x on >p nopass", None).unwrap();
assert!(user.validate_password(&AclPassword::from_string("anything")));
let user = AclParser::parse_acl_rule("user x on nopass resetpass", None).unwrap();
assert!(!user.validate_password(&AclPassword::from_string("anything")));
}
#[test]
fn parse_acl_rule_flag_ops() {
let user = AclParser::parse_acl_rule("user x on +set ~* resetkeys", None).unwrap();
assert!(user.is_enabled());
assert!(user.can_access_command(RespCommand::Set));
let user = AclParser::parse_acl_rule("user x on off", None).unwrap();
assert!(!user.is_enabled());
let user = AclParser::parse_acl_rule("user x on +set >p reset", None).unwrap();
assert!(!user.is_enabled());
assert!(!user.can_access_command(RespCommand::Set));
assert!(!user.validate_password(&AclPassword::from_string("p")));
}
#[test]
fn try_parse_command_for_acl_cases() {
assert_eq!(
AclParser::try_parse_command_for_acl("GET"),
Some(RespCommand::Get)
);
assert_eq!(
AclParser::try_parse_command_for_acl("client|getname"),
Some(RespCommand::ClientGetname)
);
assert_eq!(AclParser::try_parse_command_for_acl("a|b|c"), None);
assert_eq!(
AclParser::try_parse_command_for_acl("ri.create"),
Some(RespCommand::Ricreate)
);
assert_eq!(
AclParser::try_parse_command_for_acl("slaveof"),
Some(RespCommand::Secondaryof)
);
assert_eq!(
AclParser::try_parse_command_for_acl("cluster|set-config-epoch"),
Some(RespCommand::ClusterSetconfigepoch)
);
assert_eq!(AclParser::try_parse_command_for_acl("setexnx"), None);
assert_eq!(AclParser::try_parse_command_for_acl("get123"), None);
assert_eq!(AclParser::try_parse_command_for_acl("nosuchcmd"), None);
}
#[test]
fn is_valid_custom_command_name_cases() {
assert!(AclParser::is_valid_custom_command_name("json.set"));
assert!(AclParser::is_valid_custom_command_name("JSON|SET"));
assert!(AclParser::is_valid_custom_command_name("a-b_c"));
assert!(AclParser::is_valid_custom_command_name("1abc"));
assert!(!AclParser::is_valid_custom_command_name(""));
assert!(!AclParser::is_valid_custom_command_name("bad name"));
assert!(!AclParser::is_valid_custom_command_name("bad!name"));
}
#[test]
fn category_lookup() {
assert_eq!(
AclParser::get_acl_category_by_name("KEYSPACE"),
Some(RespAclCategories::KEYSPACE)
);
assert_eq!(
AclParser::get_acl_category_by_name("all"),
Some(RespAclCategories::ALL)
);
assert_eq!(AclParser::get_acl_category_by_name("nosuch"), None);
assert_eq!(
AclParser::get_name_by_acl_category(RespAclCategories::HYPERLOGLOG),
"hyperloglog"
);
assert_eq!(
AclParser::get_name_by_acl_category(RespAclCategories::ALL),
"all"
);
let names = AclParser::list_categories();
assert_eq!(names.len(), 25);
assert!(names.contains(&"admin"));
assert!(names.contains(&"all"));
}
#[test]
fn parse_acl_rule_with_acl_mutates_list() {
let acl = AccessControlList::new("", None).unwrap();
AclParser::parse_acl_rule("user alice on +set", Some(&acl)).unwrap();
let handle = acl.get_user_handle("alice").expect("alice added");
assert!(handle.user().can_access_command(RespCommand::Set));
AclParser::parse_acl_rule("user alice on +get", Some(&acl)).unwrap();
assert!(
acl
.get_user_handle("alice")
.unwrap()
.user()
.can_access_command(RespCommand::Get)
);
assert!(
acl
.get_user_handle("alice")
.unwrap()
.user()
.can_access_command(RespCommand::Set)
);
}
}