use std::{collections::BTreeMap, fs, path::PathBuf, sync::OnceLock};
use serde_derive::{Deserialize, Serialize};
use tracing::debug;
use crate::{
config::AclSettings,
error::{AppResult, ErrorKind},
typed_path::TpeKind,
};
pub static ACL: OnceLock<Acl> = OnceLock::new();
pub fn init_acl(acl: Acl) -> AppResult<()> {
let _ = ACL.get_or_init(|| acl);
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Serialize, Deserialize, Copy)]
pub enum AccessType {
NoAccess,
ForceUnlock,
Read,
Append,
Modify,
}
pub trait AclChecker: Send + Sync + 'static {
fn is_allowed(&self, user: &str, path: &str, tpe: Option<TpeKind>, access: AccessType) -> bool;
}
type HtPasswdUsername = String;
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
pub struct RepoAcl(BTreeMap<HtPasswdUsername, AccessType>);
impl RepoAcl {
pub fn new() -> Self {
Self::default()
}
}
impl std::ops::DerefMut for RepoAcl {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl std::ops::Deref for RepoAcl {
type Target = BTreeMap<String, AccessType>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
type Repository = String;
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Acl {
private_repo: bool,
append_only: bool,
repos: BTreeMap<Repository, RepoAcl>,
}
impl Default for Acl {
fn default() -> Self {
Self {
repos: BTreeMap::new(),
append_only: true,
private_repo: true,
}
}
}
fn read_toml(file_path: &PathBuf) -> AppResult<BTreeMap<String, RepoAcl>> {
let s = fs::read_to_string(file_path).map_err(|err| {
ErrorKind::Io.context(format!(
"Could not read toml file: {} at {:?}",
err, file_path
))
})?;
let s = Box::leak(s.into_boxed_str());
let mut repos: BTreeMap<String, RepoAcl> = toml::from_str(s)
.map_err(|err| ErrorKind::Config.context(format!("Could not parse TOML: {}", err)))?;
if let Some(default) = repos.get("default") {
let default = default.clone();
let _ = repos.insert(String::new(), default);
}
Ok(repos)
}
impl Acl {
pub fn from_file(
append_only: bool,
private_repos: bool,
file_path: Option<PathBuf>,
) -> AppResult<Self> {
let repos = match file_path {
Some(file_path) => read_toml(&file_path).map_err(|err| {
ErrorKind::Config.context(format!("Could not read ACL file: {err}"))
})?,
None => BTreeMap::new(),
};
Ok(Self {
append_only,
private_repo: private_repos,
repos,
})
}
pub fn from_config(settings: &AclSettings, path: Option<PathBuf>) -> AppResult<Self> {
Self::from_file(
settings.append_only,
!settings.disable_acl || settings.private_repos,
path,
)
}
pub fn to_file(&self, pth: &PathBuf) -> AppResult<()> {
let mut repos = self.repos.clone();
_ = repos.remove("");
let toml_string = toml::to_string(&repos).map_err(|err| {
ErrorKind::Config.context(format!(
"Could not serialize ACL config to TOML value: {err}"
))
})?;
fs::write(pth, toml_string)
.map_err(|err| ErrorKind::Io.context(format!("Could not write ACL file: {err}")))?;
Ok(())
}
pub fn set_append_only(self, append_only: bool) -> Self {
Self {
append_only,
..self
}
}
pub fn default_repo_access(&mut self, user: &str, access: AccessType) {
if !self.repos.contains_key("default") {
let mut acl = RepoAcl::new();
_ = acl.insert(user.into(), access);
_ = self.repos.insert("default".to_owned(), acl.clone());
_ = self.repos.insert(String::new(), acl);
} else {
_ = self
.repos
.get_mut("default")
.unwrap()
.insert(user.into(), access);
_ = self.repos.get_mut("").unwrap().insert(user.into(), access);
}
}
}
impl AclChecker for Acl {
#[tracing::instrument(level = "debug", skip(self))]
fn is_allowed(
&self,
user: &str,
path: &str,
tpe: Option<TpeKind>,
access_type: AccessType,
) -> bool {
let access_type = if tpe.is_some_and(|v| v == TpeKind::Locks) {
AccessType::Read
} else {
access_type
};
self.repos.get(path).map_or_else(
|| {
debug!("No ACL for repository found, applying default ACL.");
let is_user_path = user == path;
let is_not_private_repo = !self.private_repo;
let is_not_modify_access = access_type != AccessType::Modify;
let is_not_append_only = !self.append_only;
debug!(%is_user_path, %is_not_private_repo, %is_not_modify_access, %is_not_append_only);
let access = (is_user_path || is_not_private_repo)
&& (is_not_modify_access || is_not_append_only);
debug!(%access, "Access check");
access
},
|repo_acl| {
let access =
matches!(repo_acl.get(user), Some(user_access) if user_access >= &access_type);
debug!(?repo_acl, %access, "Access check");
access
},
)
}
}
#[cfg(test)]
mod tests {
use super::AccessType::{Append, Modify, Read};
use super::*;
use crate::testing::server_config;
use rstest::rstest;
use std::env;
#[rstest]
fn test_static_acl_access_passes() {
let acl = server_config().acl;
let auth = Acl::from_config(&acl.clone(), acl.acl_path).unwrap();
init_acl(auth).unwrap();
let acl = ACL.get().unwrap();
assert!(&acl.private_repo);
assert!(!&acl.append_only);
let access = acl.repos.get("test_repo").unwrap();
let access_type = access.get("rustic").unwrap();
assert_eq!(access_type, &Append);
}
#[test]
fn test_allowed_flags_passes() {
let mut acl = Acl::default();
insta::assert_debug_snapshot!("acl_default_impl", acl);
assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Keys), Read));
assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Data), Read));
assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Data), Append));
assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Data), Modify));
assert!(!acl.is_allowed("bob", "bob", Some(TpeKind::Data), Modify));
assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Locks), Modify));
assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Keys), Append));
assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Data), Append));
assert!(acl.is_allowed("", "", Some(TpeKind::Data), Append));
assert!(!acl.is_allowed("bob", "", Some(TpeKind::Data), Read));
acl.append_only = false;
assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Data), Modify));
assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Data), Modify));
acl.private_repo = false;
assert!(acl.is_allowed("bob", "sam", Some(TpeKind::Data), Modify));
assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Data), Modify));
assert!(acl.is_allowed("bob", "", Some(TpeKind::Data), Modify));
}
#[test]
fn test_repo_acl_passes() {
let mut acl = Acl::default();
let mut acl_all = RepoAcl::new();
_ = acl_all.insert("bob".to_string(), Modify);
_ = acl_all.insert("sam".to_string(), Append);
_ = acl_all.insert("paul".to_string(), Read);
_ = acl.repos.insert("all".to_string(), acl_all);
let mut acl_bob = RepoAcl::new();
_ = acl_bob.insert("bob".to_string(), Modify);
_ = acl.repos.insert("bob".to_string(), acl_bob);
let mut acl_sam = RepoAcl::new();
_ = acl_sam.insert("sam".to_string(), Append);
_ = acl_sam.insert("bob".to_string(), Read);
_ = acl.repos.insert("sam".to_string(), acl_sam);
insta::assert_debug_snapshot!(acl);
assert!(acl.is_allowed("paul", "all", Some(TpeKind::Data), Read));
assert!(acl.is_allowed("sam", "all", Some(TpeKind::Keys), Append));
assert!(!acl.is_allowed("paul", "all", Some(TpeKind::Data), Append));
assert!(acl.is_allowed("bob", "all", Some(TpeKind::Keys), Modify));
assert!(!acl.is_allowed("sam", "all", Some(TpeKind::Keys), Modify));
assert!(acl.is_allowed("sam", "all", Some(TpeKind::Locks), Modify));
assert!(acl.is_allowed("paul", "all", Some(TpeKind::Locks), Modify));
assert!(!acl.is_allowed("attack", "all", Some(TpeKind::Data), Modify));
assert!(!acl.is_allowed("sam", "bob", Some(TpeKind::Data), Read));
assert!(acl.is_allowed("bob", "bob", Some(TpeKind::Data), Modify));
assert!(!acl.is_allowed("attack", "bob", Some(TpeKind::Locks), Modify));
assert!(acl.is_allowed("bob", "sam", Some(TpeKind::Keys), Read));
assert!(!acl.is_allowed("attack", "sam", Some(TpeKind::Locks), Read));
assert!(acl.is_allowed("sam", "sam", Some(TpeKind::Data), Append));
assert!(!acl.is_allowed("bob", "sam", Some(TpeKind::Keys), Append));
assert!(!acl.is_allowed("sam", "sam", Some(TpeKind::Data), Modify));
assert!(!acl.is_allowed("sam", "paul", Some(TpeKind::Data), Read));
assert!(acl.is_allowed("paul", "paul", Some(TpeKind::Data), Append));
assert!(!acl.is_allowed("paul", "paul", Some(TpeKind::Data), Modify));
}
}