use std::sync::atomic::{AtomicBool, Ordering};
use gxhash::{HashSet, HashSetExt};
use parking_lot::{Mutex, RwLock};
use super::{
AclPassword, RespAclCategories,
acl_exception::AclError,
acl_parser::AclParser,
command_catalog::{
CmdEntry, children_of, try_get_commands_for_acl_category, try_get_resp_command_info,
},
command_permission_set::CommandPermissionSet,
};
use crate::types::RespCommand;
pub struct User {
pub name: String,
is_enabled: AtomicBool,
is_passwordless: AtomicBool,
enabled_commands: RwLock<CommandPermissionSet>,
password_hashes: Mutex<HashSet<AclPassword>>,
}
impl User {
pub fn new(name: String) -> Self {
Self {
name,
is_enabled: AtomicBool::new(false),
is_passwordless: AtomicBool::new(false),
enabled_commands: RwLock::new(CommandPermissionSet::none()),
password_hashes: Mutex::new(HashSet::new()),
}
}
pub fn from_user(user: &Self) -> Self {
Self {
name: user.name.clone(),
is_enabled: AtomicBool::new(user.is_enabled()),
is_passwordless: AtomicBool::new(user.is_passwordless()),
enabled_commands: RwLock::new(user.copy_command_permission_set()),
password_hashes: Mutex::new(user.copy_password_hashes()),
}
}
#[inline]
pub fn is_enabled(&self) -> bool {
self.is_enabled.load(Ordering::Relaxed)
}
#[inline]
pub fn set_enabled(&self, enabled: bool) {
self.is_enabled.store(enabled, Ordering::Relaxed);
}
#[inline]
pub fn is_passwordless(&self) -> bool {
self.is_passwordless.load(Ordering::Relaxed)
}
#[inline]
pub fn set_passwordless(&self, passwordless: bool) {
self.is_passwordless.store(passwordless, Ordering::Relaxed);
}
#[inline]
pub fn can_access_command(&self, command: RespCommand) -> bool {
self.enabled_commands.read().can_run_command(command)
}
#[inline]
pub fn can_access_custom_command(&self, generic_cmd: RespCommand, custom_name: &str) -> bool {
self
.enabled_commands
.read()
.can_run_custom_command(generic_cmd, custom_name)
}
pub fn add_category(&self, category: RespAclCategories) -> Result<(), AclError> {
let mut perms = self.enabled_commands.write();
if perms.is_all() {
return Ok(());
}
if category != RespAclCategories::ALL {
let command_infos = try_get_commands_for_acl_category(category).ok_or_else(|| {
AclError::Acl("Unable to obtain ACL information, this shouldn't be possible".into())
})?;
let cmds = Self::determine_command_details(&command_infos);
if cmds.iter().all(|&cmd| perms.can_run_command(cmd)) {
return Ok(());
}
let desc_update = format!("+@{}", AclParser::get_name_by_acl_category(category));
let mut updated = perms.copy();
let mut deep = false;
for cmd in cmds {
deep = deep || updated.can_run_command(cmd);
updated.add_command(cmd);
}
updated.description = Self::rationalize_acl_description(
&updated,
&format!("{} {desc_update}", updated.description),
deep,
);
*perms = updated;
} else {
*perms = CommandPermissionSet::all();
}
Ok(())
}
pub fn add_command(&self, command: RespCommand) -> Result<(), AclError> {
let info = try_get_resp_command_info(command).ok_or_else(|| {
AclError::Acl("Unable to obtain ACL information, this shouldn't be possible".into())
})?;
let to_add = Self::determine_command_details(&[info]);
let mut perms = self.enabled_commands.write();
if to_add.iter().all(|&cmd| perms.can_run_command(cmd)) {
return Ok(());
}
let desc_update = format!("+{}", info.name);
let mut updated = perms.copy();
let mut deep = false;
for cmd in to_add {
deep = deep || updated.can_run_command(cmd);
updated.add_command(cmd);
}
updated.description = Self::rationalize_acl_description(
&updated,
&format!("{} {desc_update}", updated.description),
deep,
);
*perms = updated;
Ok(())
}
pub fn remove_category(&self, category: RespAclCategories) -> Result<(), AclError> {
let mut perms = self.enabled_commands.write();
if perms.is_none() {
return Ok(());
}
if category != RespAclCategories::ALL {
let command_infos = try_get_commands_for_acl_category(category).ok_or_else(|| {
AclError::Acl("Unable to obtain ACL information, this shouldn't be possible".into())
})?;
let cmds = Self::determine_command_details(&command_infos);
if !cmds.iter().any(|&cmd| perms.can_run_command(cmd)) {
return Ok(());
}
let desc_update = format!("-@{}", AclParser::get_name_by_acl_category(category));
let mut updated = perms.copy();
let mut deep = false;
for cmd in cmds {
deep = deep || updated.can_run_command(cmd);
updated.remove_command(cmd);
}
updated.description = Self::rationalize_acl_description(
&updated,
&format!("{} {desc_update}", updated.description),
deep,
);
*perms = updated;
} else {
*perms = CommandPermissionSet::none();
}
Ok(())
}
pub fn remove_command(&self, command: RespCommand) -> Result<(), AclError> {
let info = try_get_resp_command_info(command).ok_or_else(|| {
AclError::Acl("Unable to obtain ACL information, this shouldn't be possible".into())
})?;
let to_remove = Self::determine_command_details(&[info]);
let mut perms = self.enabled_commands.write();
if to_remove.iter().all(|&cmd| !perms.can_run_command(cmd)) {
return Ok(());
}
let desc_update = format!("-{}", info.name);
let mut updated = perms.copy();
let mut deep = false;
for cmd in to_remove {
deep = deep || updated.can_run_command(cmd);
updated.remove_command(cmd);
}
updated.description = Self::rationalize_acl_description(
&updated,
&format!("{} {desc_update}", updated.description),
deep,
);
*perms = updated;
Ok(())
}
pub fn add_custom_command(&self, custom_name: &str) -> Result<(), AclError> {
if !AclParser::is_valid_custom_command_name(custom_name) {
return Err(AclError::Acl(format!(
"Invalid custom command name '{custom_name}'"
)));
}
let normalized = custom_name.to_ascii_uppercase();
let desc_update = normalized.to_ascii_lowercase();
let mut perms = self.enabled_commands.write();
if perms.is_all()
|| (perms.custom_allowed().contains(&normalized)
&& !perms.custom_denied().contains(&normalized))
{
return Ok(());
}
let mut updated = perms.copy();
updated.add_custom_command(&normalized);
updated.description = Self::rationalize_acl_description(
&updated,
&format!("{} +{desc_update}", updated.description),
false,
);
*perms = updated;
Ok(())
}
pub fn remove_custom_command(&self, custom_name: &str) -> Result<(), AclError> {
if !AclParser::is_valid_custom_command_name(custom_name) {
return Err(AclError::Acl(format!(
"Invalid custom command name '{custom_name}'"
)));
}
let normalized = custom_name.to_ascii_uppercase();
let desc_update = normalized.to_ascii_lowercase();
let mut perms = self.enabled_commands.write();
if !perms.is_all()
&& perms.custom_denied().contains(&normalized)
&& !perms.custom_allowed().contains(&normalized)
{
return Ok(());
}
let mut updated = perms.copy();
updated.remove_custom_command(&normalized);
updated.description = Self::rationalize_acl_description(
&updated,
&format!("{} -{desc_update}", updated.description),
false,
);
*perms = updated;
Ok(())
}
pub fn add_password_hash(&self, password: AclPassword) {
self.password_hashes.lock().insert(password);
}
pub fn remove_password_hash(&self, password: AclPassword) {
self.password_hashes.lock().remove(&password);
}
pub fn clear_passwords(&self) {
self.password_hashes.lock().clear();
}
pub fn reset(&self) {
self.clear_passwords();
*self.enabled_commands.write() = CommandPermissionSet::none();
self.set_enabled(false);
}
pub fn validate_password(&self, password: &AclPassword) -> bool {
if self.is_passwordless() {
return true;
}
self.password_hashes.lock().contains(password)
}
pub fn describe_user(&self) -> String {
let mut out = format!("user {}", self.name);
out.push_str(if self.is_enabled() { " on" } else { " off" });
if self.is_passwordless() {
out.push_str(" nopass");
}
for hash in self.password_hashes.lock().iter() {
out.push_str(&format!(" #{hash}"));
}
let perms_str = self.enabled_commands.read().description.clone();
if !perms_str.trim().is_empty() {
out.push(' ');
out.push_str(&perms_str);
}
out
}
pub fn get_enabled_commands_description(&self) -> String {
self.enabled_commands.read().description.clone()
}
pub fn custom_commands_allowed(&self) -> HashSet<String> {
self.enabled_commands.read().custom_allowed().clone()
}
pub fn custom_commands_denied(&self) -> HashSet<String> {
self.enabled_commands.read().custom_denied().clone()
}
pub(crate) fn determine_command_details(infos: &[&CmdEntry]) -> Vec<RespCommand> {
let mut cmds = Vec::new();
for info in infos {
cmds.push(info.cmd);
if info.parent.is_none() {
cmds.extend(children_of(info.cmd).map(|sub| sub.cmd));
}
}
cmds
}
fn rationalize_acl_description(
set: &CommandPermissionSet,
description: &str,
use_deep_rationalization: bool,
) -> String {
let mut parts: Vec<&str> = description
.split(' ')
.filter(|p| !p.trim().is_empty())
.collect();
if use_deep_rationalization {
let mut shrunk = true;
while shrunk {
shrunk = false;
let mut i = 0;
while i < parts.len() {
let mut without_rule = String::from("user test on >xxx");
let kept: &[&str] = if i > 1 { &parts[1..i] } else { &[] };
for part in kept {
without_rule.push(' ');
without_rule.push_str(part);
}
if let Ok(without_user) = AclParser::parse_acl_rule(&without_rule, None)
&& without_user
.copy_command_permission_set()
.is_equivalent_to(set)
{
parts.remove(i);
shrunk = true;
continue; }
i += 1;
}
}
}
parts.join(" ")
}
pub fn copy_command_permission_set(&self) -> CommandPermissionSet {
self.enabled_commands.read().copy()
}
pub fn copy_password_hashes(&self) -> HashSet<AclPassword> {
self.password_hashes.lock().clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn describe_user_format() {
let default_user = User::new("default".into());
default_user.add_category(RespAclCategories::ALL).unwrap();
default_user.set_enabled(true);
default_user.set_passwordless(true);
assert_eq!(default_user.describe_user(), "user default on nopass +@all");
let u = User::new("x".into());
assert_eq!(u.describe_user(), "user x off");
u.set_enabled(true);
u.add_password_hash(AclPassword::from_string("passw0rd"));
let described = u.describe_user();
assert_eq!(
described,
"user x on #8f0e2f76e22b43e2855189877e7dc1e1e7d98c226c95db247cd1d547928334a9".to_string()
);
}
#[test]
fn access_decisions() {
let u = User::new("x".into());
assert!(!u.can_access_command(RespCommand::Get));
u.add_command(RespCommand::Get).unwrap();
assert!(u.can_access_command(RespCommand::Get));
assert!(!u.can_access_command(RespCommand::Set));
u.remove_command(RespCommand::Get).unwrap();
u.add_category(RespAclCategories::KEYSPACE).unwrap();
assert!(u.can_access_command(RespCommand::Del));
u.remove_category(RespAclCategories::KEYSPACE).unwrap();
assert!(!u.can_access_command(RespCommand::Del));
u.add_custom_command("json.set").unwrap();
assert!(u.can_access_custom_command(RespCommand::Customrawstringcmd, "JSON.SET"));
u.remove_custom_command("json.set").unwrap();
assert!(!u.can_access_custom_command(RespCommand::Customrawstringcmd, "json.set"));
assert!(u.add_custom_command("bad name").is_err());
}
#[test]
fn no_auth_commands_always_accessible_via_all() {
let u = User::new("x".into());
u.add_category(RespAclCategories::ALL).unwrap();
u.remove_command(RespCommand::Auth).unwrap();
for cmd in [RespCommand::Auth, RespCommand::Hello, RespCommand::Quit] {
assert!(u.can_access_command(cmd));
}
}
#[test]
fn copy_constructor_isolates() {
let src = User::new("a".into());
src.set_enabled(true);
src.add_password_hash(AclPassword::from_string("p"));
src.add_command(RespCommand::Get).unwrap();
let snapshot = User::from_user(&src);
src.add_command(RespCommand::Set).unwrap();
src.add_password_hash(AclPassword::from_string("q"));
src.set_enabled(false);
assert!(snapshot.is_enabled());
assert!(snapshot.can_access_command(RespCommand::Get));
assert!(!snapshot.can_access_command(RespCommand::Set));
assert_eq!(snapshot.copy_password_hashes().len(), 1);
}
#[test]
fn validate_password_semantics() {
let u = User::new("x".into());
u.add_password_hash(AclPassword::from_string("a"));
u.add_password_hash(AclPassword::from_string("b"));
assert!(u.validate_password(&AclPassword::from_string("a")));
assert!(u.validate_password(&AclPassword::from_string("b")));
assert!(!u.validate_password(&AclPassword::from_string("c")));
u.set_passwordless(true);
assert!(u.validate_password(&AclPassword::from_string("anything")));
u.set_passwordless(false);
u.remove_password_hash(AclPassword::from_string("a"));
assert!(!u.validate_password(&AclPassword::from_string("a")));
assert!(u.validate_password(&AclPassword::from_string("b")));
}
}