Files
muscl/src/core/protocol/request_validation.rs
h7x4 4c3677d6d3
All checks were successful
Build and test / docs (push) Successful in 7m1s
Build and test / check-license (push) Successful in 57s
Build and test / check (push) Successful in 2m46s
Build and test / build (push) Successful in 3m12s
Build and test / test (push) Successful in 3m25s
clippy pedantic fix + get rid of a few unwraps
2025-12-23 14:12:39 +09:00

291 lines
8.8 KiB
Rust

use std::collections::HashSet;
use indoc::indoc;
use nix::{libc::gid_t, unistd::Group};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::core::{common::UnixUser, types::DbOrUser};
#[derive(Error, Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum NameValidationError {
#[error("Name cannot be empty.")]
EmptyString,
#[error(
"Name contains invalid characters. Only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted."
)]
InvalidCharacters,
#[error("Name is too long. Maximum length is 64 characters.")]
TooLong,
}
impl NameValidationError {
#[must_use]
pub fn to_error_message(self, db_or_user: &DbOrUser) -> String {
match self {
NameValidationError::EmptyString => {
format!("{} name can not be empty.", db_or_user.capitalized_noun())
}
NameValidationError::TooLong => format!(
"{} is too long, maximum length is 64 characters.",
db_or_user.capitalized_noun()
),
NameValidationError::InvalidCharacters => format!(
indoc! {r"
Invalid characters in {} name: '{}', only A-Z, a-z, 0-9, _ (underscore) and - (dash) are permitted.
"},
db_or_user.lowercased_noun(),
db_or_user.name(),
),
}
}
#[must_use]
pub fn error_type(&self) -> &'static str {
match self {
NameValidationError::EmptyString => "empty-string",
NameValidationError::InvalidCharacters => "invalid-characters",
NameValidationError::TooLong => "too-long",
}
}
}
#[derive(Error, Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
pub enum AuthorizationError {
#[error("Illegal prefix, user is not authorized to manage this resource")]
IllegalPrefix,
// TODO: I don't think this should ever happen?
#[error("Name cannot be empty")]
StringEmpty,
#[error("Group was found in denylist")]
DenylistError,
}
impl AuthorizationError {
#[must_use]
pub fn to_error_message(self, db_or_user: &DbOrUser) -> String {
match self {
AuthorizationError::IllegalPrefix => format!(
"Illegal {} name prefix: you are not allowed to manage databases or users prefixed with '{}'",
db_or_user.lowercased_noun(),
db_or_user.prefix(),
)
.to_owned(),
// TODO: This error message could be clearer
AuthorizationError::StringEmpty => {
format!("{} name can not be empty.", db_or_user.capitalized_noun())
}
AuthorizationError::DenylistError => {
format!("'{}' is denied by the group denylist", db_or_user.name())
}
}
}
#[must_use]
pub fn error_type(&self) -> &'static str {
match self {
AuthorizationError::IllegalPrefix => "illegal-prefix",
AuthorizationError::StringEmpty => "string-empty",
AuthorizationError::DenylistError => "denylist-error",
}
}
}
#[derive(Error, Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum ValidationError {
#[error("Name validation error: {0}")]
NameValidationError(NameValidationError),
#[error("Authorization error: {0}")]
AuthorizationError(AuthorizationError),
// AuthorizationHandlerError(String),
}
impl ValidationError {
#[must_use]
pub fn to_error_message(&self, db_or_user: &DbOrUser) -> String {
match self {
ValidationError::NameValidationError(err) => err.to_error_message(db_or_user),
ValidationError::AuthorizationError(err) => err.to_error_message(db_or_user),
// AuthorizationError::AuthorizationHandlerError(msg) => {
// format!(
// "Authorization handler error for '{}': {}",
// db_or_user.name(),
// msg
// )
// }
}
}
#[must_use]
pub fn error_type(&self) -> String {
match self {
ValidationError::NameValidationError(err) => {
format!("name-validation-error/{}", err.error_type())
}
ValidationError::AuthorizationError(err) => {
format!("authorization-error/{}", err.error_type())
} // AuthorizationError::AuthorizationHandlerError(_) => {
// "authorization-handler-error".to_string()
// }
}
}
}
pub type GroupDenylist = HashSet<gid_t>;
const MAX_NAME_LENGTH: usize = 64;
pub fn validate_name(name: &str) -> Result<(), NameValidationError> {
if name.is_empty() {
Err(NameValidationError::EmptyString)
} else if name.len() > MAX_NAME_LENGTH {
Err(NameValidationError::TooLong)
} else if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
Err(NameValidationError::InvalidCharacters)
} else {
Ok(())
}
}
pub fn validate_authorization_by_unix_user(
name: &str,
user: &UnixUser,
) -> Result<(), AuthorizationError> {
let prefixes = std::iter::once(user.username.clone())
.chain(user.groups.iter().cloned())
.collect::<Vec<String>>();
validate_authorization_by_prefixes(name, &prefixes)
}
/// Core logic for validating the ownership of a database name.
/// This function checks if the given name matches any of the given prefixes.
/// These prefixes will in most cases be the user's unix username and any
/// unix groups the user is a member of.
pub fn validate_authorization_by_prefixes(
name: &str,
prefixes: &[String],
) -> Result<(), AuthorizationError> {
if name.is_empty() {
return Err(AuthorizationError::StringEmpty);
}
if prefixes
.iter()
.filter(|p| name.starts_with(&((*p).clone() + "_")))
.collect::<Vec<_>>()
.is_empty()
{
return Err(AuthorizationError::IllegalPrefix);
}
Ok(())
}
pub fn validate_authorization_by_group_denylist(
name: &str,
user: &UnixUser,
group_denylist: &GroupDenylist,
) -> Result<(), AuthorizationError> {
// NOTE: if the username matches, we allow it regardless of denylist
if user.username == name {
return Ok(());
}
let user_group = Group::from_name(name)
.ok()
.flatten()
.map(|g| g.gid.as_raw());
if let Some(gid) = user_group
&& group_denylist.contains(&gid)
{
Err(AuthorizationError::DenylistError)
} else {
Ok(())
}
}
pub fn validate_db_or_user_request(
db_or_user: &DbOrUser,
unix_user: &UnixUser,
group_denylist: &GroupDenylist,
) -> Result<(), ValidationError> {
validate_name(db_or_user.name()).map_err(ValidationError::NameValidationError)?;
validate_authorization_by_unix_user(db_or_user.name(), unix_user)
.map_err(ValidationError::AuthorizationError)?;
validate_authorization_by_group_denylist(db_or_user.name(), unix_user, group_denylist)
.map_err(ValidationError::AuthorizationError)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_name() {
assert_eq!(validate_name(""), Err(NameValidationError::EmptyString));
assert_eq!(validate_name("abcdefghijklmnopqrstuvwxyz"), Ok(()));
assert_eq!(validate_name("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), Ok(()));
assert_eq!(validate_name("0123456789_-"), Ok(()));
for c in "\n\t\r !@#$%^&*()+=[]{}|;:,.<>?/".chars() {
assert_eq!(
validate_name(&c.to_string()),
Err(NameValidationError::InvalidCharacters)
);
}
assert_eq!(validate_name(&"a".repeat(MAX_NAME_LENGTH)), Ok(()));
assert_eq!(
validate_name(&"a".repeat(MAX_NAME_LENGTH + 1)),
Err(NameValidationError::TooLong)
);
}
#[test]
fn test_validate_authorization_by_prefixes() {
let prefixes = vec!["user".to_string(), "group".to_string()];
assert_eq!(
validate_authorization_by_prefixes("", &prefixes),
Err(AuthorizationError::StringEmpty)
);
assert_eq!(
validate_authorization_by_prefixes("user_testdb", &prefixes),
Ok(())
);
assert_eq!(
validate_authorization_by_prefixes("group_testdb", &prefixes),
Ok(())
);
assert_eq!(
validate_authorization_by_prefixes("group_test_db", &prefixes),
Ok(())
);
assert_eq!(
validate_authorization_by_prefixes("group_test-db", &prefixes),
Ok(())
);
assert_eq!(
validate_authorization_by_prefixes("nonexistent_testdb", &prefixes),
Err(AuthorizationError::IllegalPrefix)
);
}
}