use crate::{ActError, Result};
use globset::{Glob, GlobMatcher};
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
pub const ACTION_WHOAMI: &str = "acl:whoami";
pub const ACTION_SUBSCRIBE: &str = "msg:sub";
pub const ANONYMOUS_ROLE: &str = "anonymous";
const ANONYMOUS_ALLOW: &[&str] = &["model:ls", "model:get", "pack:get", "pack:ls"];
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct AclConfig {
pub enabled: Option<bool>,
pub token: Option<String>,
pub default_role: Option<String>,
pub workdir: Option<String>,
pub role: Vec<RoleConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RoleConfig {
pub name: String,
#[serde(default)]
pub tokens: Vec<String>,
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub snapshot: HashMap<String, Vec<String>>,
#[serde(default)]
pub workdir: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AclError {
Unauthenticated(String),
Denied(String),
}
impl fmt::Display for AclError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AclError::Unauthenticated(msg) => write!(f, "unauthenticated: {msg}"),
AclError::Denied(msg) => write!(f, "permission denied: {msg}"),
}
}
}
impl std::error::Error for AclError {}
impl From<AclError> for ActError {
fn from(err: AclError) -> Self {
match err {
AclError::Unauthenticated(msg) => ActError::Unauthenticated(msg),
AclError::Denied(msg) => ActError::Denied(msg),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScopePolicy {
#[serde(default)]
pub subject: String,
#[serde(default)]
pub all: bool,
#[serde(default)]
pub scopes: HashMap<String, Vec<String>>,
#[serde(default)]
pub workdir_root: Option<PathBuf>,
}
impl Default for ScopePolicy {
fn default() -> Self {
Self::deny_all()
}
}
impl ScopePolicy {
pub fn unrestricted() -> Self {
Self {
subject: String::new(),
all: true,
scopes: HashMap::new(),
workdir_root: None,
}
}
pub fn deny_all() -> Self {
Self {
subject: String::new(),
all: false,
scopes: HashMap::new(),
workdir_root: None,
}
}
pub fn allows(&self, target: &str, scope: &str) -> bool {
if self.all {
return true;
}
match self.scopes.get(target) {
Some(patterns) => patterns
.iter()
.any(|pattern| scope_matches(&substitute(pattern, &self.subject), scope)),
None => false,
}
}
}
#[derive(Debug, Clone)]
pub struct Principal {
subject: String,
roles: Vec<String>,
authenticated: bool,
all: bool,
allow: Vec<GlobMatcher>,
deny: Vec<GlobMatcher>,
allow_pat: Vec<String>,
deny_pat: Vec<String>,
scopes: HashMap<String, Vec<String>>,
workdir_root: Option<PathBuf>,
}
impl Principal {
pub fn unrestricted() -> Self {
Self {
subject: "system".to_string(),
roles: Vec::new(),
authenticated: true,
all: true,
allow: Vec::new(),
deny: Vec::new(),
allow_pat: vec!["*".to_string()],
deny_pat: Vec::new(),
scopes: HashMap::new(),
workdir_root: None,
}
}
pub fn anonymous() -> Self {
Self {
subject: "anonymous".to_string(),
roles: Vec::new(),
authenticated: false,
all: false,
allow: Vec::new(),
deny: Vec::new(),
allow_pat: Vec::new(),
deny_pat: Vec::new(),
scopes: HashMap::new(),
workdir_root: None,
}
}
pub fn subject(&self) -> &str {
&self.subject
}
pub fn roles(&self) -> &[String] {
&self.roles
}
pub fn is_unrestricted(&self) -> bool {
self.all
}
pub fn is_authenticated(&self) -> bool {
self.authenticated
}
pub fn check(&self, action: &str) -> std::result::Result<(), AclError> {
if !self.authenticated {
return Err(AclError::Unauthenticated(
"a valid acl token is required; see [acl] in the config".to_string(),
));
}
if self.deny.iter().any(|glob| glob.is_match(action)) {
return Err(AclError::Denied(format!(
"{} denies action '{action}'",
self.describe()
)));
}
if self.all || self.allow.iter().any(|glob| glob.is_match(action)) {
return Ok(());
}
Err(AclError::Denied(format!(
"action '{action}' is not allowed for {}",
self.describe()
)))
}
pub fn check_scope(&self, target: &str, scope: &str) -> std::result::Result<(), AclError> {
if !self.authenticated {
return Err(AclError::Unauthenticated(
"a valid acl token is required; see [acl] in the config".to_string(),
));
}
if self.scope_policy().allows(target, scope) {
return Ok(());
}
Err(AclError::Denied(format!(
"scope '{scope}' of snapshot '{target}' is not owned by subject '{}'",
self.subject
)))
}
pub fn workdir_root(&self) -> Option<&Path> {
self.workdir_root.as_deref()
}
pub fn scope_policy(&self) -> ScopePolicy {
ScopePolicy {
subject: self.subject.clone(),
all: self.all,
scopes: self.scopes.clone(),
workdir_root: self.workdir_root.clone(),
}
}
pub fn to_value(&self) -> JsonValue {
json!({
"subject": self.subject,
"roles": self.roles,
"unrestricted": self.all,
"allow": self.allow_pat,
"deny": self.deny_pat,
"scopes": self.scopes,
"workdir_root": self.workdir_root.as_ref().map(|dir| dir.display().to_string()),
})
}
fn describe(&self) -> String {
if self.roles.is_empty() {
format!("subject '{}'", self.subject)
} else {
format!("role(s) {}", self.roles.join(", "))
}
}
}
#[derive(Debug, Clone)]
struct CompiledRole {
name: String,
all: bool,
allow: Vec<GlobMatcher>,
deny: Vec<GlobMatcher>,
allow_pat: Vec<String>,
deny_pat: Vec<String>,
scopes: HashMap<String, Vec<String>>,
workdir_root: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct Acl {
enabled: bool,
roles: Vec<CompiledRole>,
index: HashMap<String, usize>,
default_role: Option<usize>,
workdir_root: Option<PathBuf>,
}
impl Default for Acl {
fn default() -> Self {
Self::anonymous_access()
}
}
impl Acl {
pub fn anonymous_access() -> Self {
let role = CompiledRole {
name: ANONYMOUS_ROLE.to_string(),
all: false,
allow: compile_patterns(
&ANONYMOUS_ALLOW
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>(),
ANONYMOUS_ROLE,
"allow",
)
.expect("the built-in anonymous allow list is valid"),
deny: Vec::new(),
allow_pat: ANONYMOUS_ALLOW.iter().map(|a| a.to_string()).collect(),
deny_pat: Vec::new(),
scopes: HashMap::new(),
workdir_root: None,
};
Self {
enabled: true,
roles: vec![role],
index: HashMap::new(),
default_role: Some(0),
workdir_root: None,
}
}
pub fn disabled() -> Self {
Self {
enabled: false,
roles: Vec::new(),
index: HashMap::new(),
default_role: None,
workdir_root: None,
}
}
pub fn enabled(&self) -> bool {
self.enabled
}
pub fn from_config(config: &AclConfig) -> Result<Self> {
let enabled = config.enabled.unwrap_or(true);
if !enabled {
return Ok(Self::disabled());
}
let mut roles: Vec<CompiledRole> = Vec::new();
let mut index: HashMap<String, usize> = HashMap::new();
let mut seen: HashMap<String, String> = HashMap::new();
if let Some(token) = config.token.as_deref().filter(|t| !t.trim().is_empty()) {
roles.push(CompiledRole {
name: "admin".to_string(),
all: true,
allow: Vec::new(),
deny: Vec::new(),
allow_pat: vec!["*".to_string()],
deny_pat: Vec::new(),
scopes: HashMap::new(),
workdir_root: None,
});
let hash = hash_token(token)?;
index.insert(hash, 0);
}
for role in &config.role {
let idx = roles.len();
roles.push(compile_role(role)?);
for token in &role.tokens {
if token.trim().is_empty() {
continue;
}
let hash = hash_token(token)?;
if let Some(previous) = seen.insert(hash.clone(), role.name.clone()) {
return Err(ActError::Config(format!(
"acl token is assigned to both role '{previous}' and role '{}'",
role.name
)));
}
index.insert(hash, idx);
}
}
if roles.is_empty() {
return Err(ActError::Config(
"acl is enabled but neither a token nor a role is configured".to_string(),
));
}
if index.is_empty() {
return Err(ActError::Config(
"acl is enabled but no role declares any token".to_string(),
));
}
let default_role = match config.default_role.as_deref() {
None | Some("") | Some("deny") => None,
Some(name) => Some(roles.iter().position(|role| role.name == name).ok_or_else(
|| {
ActError::Config(format!(
"acl default_role '{name}' is not a configured role"
))
},
)?),
};
Ok(Self {
enabled: true,
roles,
index,
default_role,
workdir_root: compile_workdir(config.workdir.as_deref(), "acl")?,
})
}
pub fn authenticate(&self, token: Option<&str>) -> std::result::Result<Principal, AclError> {
if !self.enabled {
return Ok(Principal::unrestricted());
}
if let Some(token) = token.map(str::trim).filter(|t| !t.is_empty()) {
if let Ok(hash) = hash_token(token)
&& let Some(&idx) = self.index.get(&hash)
{
return Ok(self.principal(idx));
}
}
match self.default_role {
Some(idx) => Ok(self.principal(idx)),
None => Err(AclError::Unauthenticated(
"a valid acl token is required; see [acl] in the config".to_string(),
)),
}
}
pub fn anonymous(&self) -> Principal {
if !self.enabled {
return Principal::unrestricted();
}
match self.default_role {
Some(idx) => self.principal(idx),
None => Principal::anonymous(),
}
}
fn principal(&self, idx: usize) -> Principal {
let role = &self.roles[idx];
Principal {
subject: role.name.clone(),
roles: vec![role.name.clone()],
authenticated: true,
all: role.all,
allow: role.allow.clone(),
deny: role.deny.clone(),
allow_pat: role.allow_pat.clone(),
deny_pat: role.deny_pat.clone(),
scopes: role.scopes.clone(),
workdir_root: role
.workdir_root
.clone()
.or_else(|| self.workdir_root.clone()),
}
}
}
fn compile_role(role: &RoleConfig) -> Result<CompiledRole> {
if role.name.trim().is_empty() {
return Err(ActError::Config(
"acl role name cannot be empty".to_string(),
));
}
if role.name.contains('/') {
return Err(ActError::Config(format!(
"acl role name '{}' cannot contain '/'",
role.name
)));
}
let all = role.allow.iter().any(|p| p == "*");
let allow = compile_patterns(&role.allow, &role.name, "allow")?;
let deny = compile_patterns(&role.deny, &role.name, "deny")?;
for (target, patterns) in &role.snapshot {
if target.trim().is_empty() {
return Err(ActError::Config(format!(
"acl role '{}' has a snapshot rule without a target name",
role.name
)));
}
compile_patterns(patterns, &role.name, "snapshot")?;
}
Ok(CompiledRole {
name: role.name.clone(),
all,
allow,
deny,
allow_pat: role.allow.clone(),
deny_pat: role.deny.clone(),
scopes: role.snapshot.clone(),
workdir_root: compile_workdir(role.workdir.as_deref(), &role.name)?,
})
}
fn compile_workdir(workdir: Option<&str>, owner: &str) -> Result<Option<PathBuf>> {
match workdir {
None => Ok(None),
Some(value) if value.trim().is_empty() => Err(ActError::Config(format!(
"acl {owner} workdir cannot be empty; remove the key to run without directory control"
))),
Some(value) => Ok(Some(PathBuf::from(value.trim()))),
}
}
fn compile_patterns(patterns: &[String], role: &str, field: &str) -> Result<Vec<GlobMatcher>> {
patterns
.iter()
.map(|pattern| {
Glob::new(pattern)
.map(|glob| glob.compile_matcher())
.map_err(|err| {
ActError::Config(format!(
"acl role '{role}' has an invalid {field} pattern '{pattern}': {err}"
))
})
})
.collect()
}
fn hash_token(token: &str) -> Result<String> {
let token = token.trim();
if let Some(hex) = token.strip_prefix("sha256:") {
let hex = hex.trim().to_ascii_lowercase();
if hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Ok(hex);
}
return Err(ActError::Config(
"acl token 'sha256:…' must carry exactly 64 hex digits".to_string(),
));
}
Ok(sha256_hex(token))
}
fn sha256_hex(text: &str) -> String {
let digest = Sha256::digest(text.as_bytes());
let mut out = String::with_capacity(64);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
fn substitute(pattern: &str, subject: &str) -> String {
pattern.replace("$subject", subject)
}
fn scope_matches(pattern: &str, scope: &str) -> bool {
let is_glob = pattern
.bytes()
.any(|b| matches!(b, b'*' | b'?' | b'[' | b'{'));
if !is_glob {
return pattern == scope;
}
Glob::new(pattern)
.map(|glob| glob.compile_matcher().is_match(scope))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn config(toml_text: &str) -> AclConfig {
toml::from_str::<AclConfig>(toml_text).unwrap()
}
fn operator_acl() -> Acl {
Acl::from_config(&config(
r#"
[[role]]
name = "operator"
tokens = ["op-secret"]
allow = ["model:ls", "model:get", "proc:*", "snap:get", "snap:ls"]
deny = ["proc:start_from_model"]
snapshot = { secrets = ["$subject"], profile = ["$subject", "$subject/*"] }
"#,
))
.unwrap()
}
#[test]
fn section_present_means_enabled() {
let acl = operator_acl();
assert!(acl.enabled());
}
#[test]
fn explicit_false_disables() {
let acl = Acl::from_config(&config(r#"enabled = false"#)).unwrap();
assert!(!acl.enabled());
assert!(acl.anonymous().is_unrestricted());
assert!(acl.authenticate(None).unwrap().is_unrestricted());
}
#[test]
fn plaintext_and_hashed_tokens_both_authenticate() {
let plain = hash_token("op-secret").unwrap();
assert_eq!(plain.len(), 64);
assert_eq!(
hash_token(&format!("sha256:{plain}")).unwrap(),
hash_token("op-secret").unwrap()
);
let acl = operator_acl();
let principal = acl.authenticate(Some("op-secret")).unwrap();
assert_eq!(principal.subject(), "operator");
assert!(acl.authenticate(Some("wrong")).is_err());
assert!(acl.authenticate(None).is_err());
}
#[test]
fn unknown_token_never_falls_back_to_a_role() {
let acl = operator_acl();
let err = acl.authenticate(Some("nope")).unwrap_err();
assert!(matches!(err, AclError::Unauthenticated(_)));
}
#[test]
fn default_role_applies_to_absent_and_unknown_tokens() {
let acl = Acl::from_config(&config(
r#"
default_role = "guest"
[[role]]
name = "guest"
tokens = ["guest-token"]
allow = ["model:ls"]
"#,
))
.unwrap();
let absent = acl.authenticate(None).unwrap();
assert_eq!(absent.subject(), "guest");
assert_eq!(
acl.authenticate(Some("unknown")).unwrap().subject(),
"guest"
);
assert!(absent.check("model:ls").is_ok());
assert!(absent.check("model:deploy").is_err());
}
#[test]
fn deny_wins_over_allow() {
let acl = operator_acl();
let principal = acl.authenticate(Some("op-secret")).unwrap();
assert!(principal.check("proc:start").is_ok());
assert!(matches!(
principal.check("proc:start_from_model"),
Err(AclError::Denied(_))
));
assert!(principal.check("act:complete").is_err());
}
#[test]
fn shorthand_token_is_unrestricted() {
let acl = Acl::from_config(&config(r#"token = "root-secret""#)).unwrap();
let principal = acl.authenticate(Some("root-secret")).unwrap();
assert!(principal.is_unrestricted());
assert!(principal.check("model:rm").is_ok());
assert!(principal.check_scope("secrets", "anyone").is_ok());
}
#[test]
fn wildcard_allow_grants_every_scope() {
let acl = Acl::from_config(&config(
r#"
[[role]]
name = "root"
tokens = ["root-secret"]
allow = ["*"]
"#,
))
.unwrap();
let principal = acl.authenticate(Some("root-secret")).unwrap();
assert!(principal.is_unrestricted());
assert!(principal.check("msg:clear").is_ok());
assert!(principal.check_scope("secrets", "other").is_ok());
}
#[test]
fn scope_ownership_follows_the_subject() {
let acl = operator_acl();
let principal = acl.authenticate(Some("op-secret")).unwrap();
assert!(principal.check_scope("secrets", "operator").is_ok());
assert!(principal.check_scope("profile", "operator").is_ok());
assert!(principal.check_scope("profile", "operator/proj-a").is_ok());
assert!(principal.check_scope("secrets", "someone-else").is_err());
assert!(principal.check_scope("profile", "someone-else").is_err());
assert!(principal.check_scope("audit", "operator").is_err());
}
#[test]
fn scope_substitution_reads_the_owner_subject() {
let policy = ScopePolicy {
subject: "u1".to_string(),
all: false,
scopes: HashMap::from([("secrets".to_string(), vec!["$subject".to_string()])]),
workdir_root: None,
};
assert!(policy.allows("secrets", "u1"));
assert!(!policy.allows("secrets", "u2"));
}
#[test]
fn hashing_a_malformed_digest_is_a_config_error() {
let err = Acl::from_config(&config(
r#"
[[role]]
name = "r"
tokens = ["sha256:nothex"]
allow = ["*"]
"#,
))
.unwrap_err();
assert!(err.to_string().contains("64 hex digits"), "{err}");
}
#[test]
fn an_invalid_action_pattern_fails_startup() {
let err = Acl::from_config(&config(
r#"
[[role]]
name = "r"
tokens = ["t"]
allow = ["act:{unclosed"]
"#,
))
.unwrap_err();
assert!(err.to_string().contains("invalid allow pattern"), "{err}");
}
#[test]
fn an_enabled_acl_without_any_token_is_a_config_error() {
let err = Acl::from_config(&config(r#"enabled = true"#)).unwrap_err();
assert!(
err.to_string().contains("neither a token nor a role"),
"{err}"
);
}
#[test]
fn a_role_without_tokens_is_a_config_error() {
let err = Acl::from_config(&config(
r#"
[[role]]
name = "r"
allow = ["*"]
"#,
))
.unwrap_err();
assert!(
err.to_string().contains("no role declares any token"),
"{err}"
);
}
#[test]
fn an_empty_role_name_is_a_config_error() {
let err = Acl::from_config(&config(
r#"
[[role]]
name = " "
tokens = ["t"]
allow = ["*"]
"#,
))
.unwrap_err();
assert!(err.to_string().contains("name cannot be empty"), "{err}");
}
#[test]
fn workdir_defaults_to_none_and_role_overrides_the_section() {
let acl = operator_acl();
assert!(
acl.authenticate(Some("op-secret"))
.unwrap()
.workdir_root()
.is_none()
);
let acl = Acl::from_config(&config(
r#"
workdir = "/srv/acts"
[[role]]
name = "r"
tokens = ["t"]
allow = ["*"]
"#,
))
.unwrap();
assert_eq!(
acl.authenticate(Some("t")).unwrap().workdir_root().unwrap(),
Path::new("/srv/acts")
);
let acl = Acl::from_config(&config(
r#"
workdir = "/srv/acts"
[[role]]
name = "a"
tokens = ["ta"]
allow = ["*"]
[[role]]
name = "b"
tokens = ["tb"]
allow = ["*"]
workdir = "/srv/tenant-b"
"#,
))
.unwrap();
assert_eq!(
acl.authenticate(Some("ta"))
.unwrap()
.workdir_root()
.unwrap(),
Path::new("/srv/acts")
);
assert_eq!(
acl.authenticate(Some("tb"))
.unwrap()
.workdir_root()
.unwrap(),
Path::new("/srv/tenant-b")
);
}
#[test]
fn an_empty_workdir_is_a_config_error() {
for text in [
r#"
workdir = " "
[[role]]
name = "r"
tokens = ["t"]
allow = ["*"]
"#,
r#"
[[role]]
name = "r"
tokens = ["t"]
allow = ["*"]
workdir = ""
"#,
] {
let err = Acl::from_config(&config(text)).unwrap_err();
assert!(err.to_string().contains("workdir cannot be empty"), "{err}");
}
}
#[test]
fn whoami_reports_the_workdir_root_without_leaking_tokens() {
let acl = Acl::from_config(&config(
r#"
workdir = "/srv/acts"
[[role]]
name = "r"
tokens = ["top-secret"]
allow = ["*"]
"#,
))
.unwrap();
let value = acl.authenticate(Some("top-secret")).unwrap().to_value();
assert_eq!(value["workdir_root"], "/srv/acts");
assert!(!value.to_string().contains("top-secret"));
}
#[test]
fn a_snapshot_rule_without_a_target_is_a_config_error() {
let err = Acl::from_config(&config(
r#"
[[role]]
name = "r"
tokens = ["t"]
allow = ["*"]
snapshot = { " " = ["$subject"] }
"#,
))
.unwrap_err();
assert!(err.to_string().contains("without a target name"), "{err}");
}
#[test]
fn a_role_name_cannot_carry_the_channel_separator() {
let err = Acl::from_config(&config(
r#"
[[role]]
name = "u1/u2"
tokens = ["t"]
allow = ["*"]
"#,
))
.unwrap_err();
assert!(err.to_string().contains("cannot contain '/'"), "{err}");
}
#[test]
fn a_missing_section_is_anonymous_catalogue_only() {
let acl = Acl::anonymous_access();
assert!(acl.enabled());
let anonymous = acl.authenticate(None).unwrap();
assert_eq!(anonymous.subject(), ANONYMOUS_ROLE);
assert_eq!(
acl.authenticate(Some("anything")).unwrap().subject(),
ANONYMOUS_ROLE
);
assert_eq!(acl.anonymous().subject(), ANONYMOUS_ROLE);
assert_eq!(
ANONYMOUS_ALLOW,
["model:ls", "model:get", "pack:get", "pack:ls"],
"the anonymous grant is the catalogue, pinned exactly"
);
for action in ANONYMOUS_ALLOW {
assert!(anonymous.check(action).is_ok(), "{action} should pass");
}
for action in [
"model:deploy",
"pack:publish",
"snap:upsert",
"snap:remove",
"proc:start",
"proc:start_from_model",
"act:complete",
"evt:start",
"msg:ack",
"msg:sub",
"msg:rm",
"msg:clear",
"msg:redo",
"msg:unsub",
"model:rm",
"snap:get",
"snap:ls",
"proc:ls",
"proc:get",
"task:ls",
"task:get",
"msg:ls",
"msg:get",
"evt:ls",
"evt:get",
"ext:register_var",
] {
assert!(
matches!(anonymous.check(action), Err(AclError::Denied(_))),
"{action} must be refused, got {:?}",
anonymous.check(action)
);
}
assert!(anonymous.check_scope("profile", "u1").is_err());
}
#[test]
fn enabled_false_is_the_explicit_opt_out() {
let acl = Acl::from_config(&config("enabled = false")).unwrap();
assert!(!acl.enabled());
assert!(acl.authenticate(None).unwrap().is_unrestricted());
assert!(acl.anonymous().is_unrestricted());
}
#[test]
fn an_unsealed_policy_reads_nothing() {
let default = ScopePolicy::default();
assert_eq!(default, ScopePolicy::deny_all());
assert!(!default.allows("secrets", "u1"));
assert!(default.workdir_root.is_none());
assert!(Principal::unrestricted().scope_policy().all);
assert!(!Principal::anonymous().scope_policy().all);
}
#[test]
fn subscribing_needs_the_grant() {
let acl = Acl::from_config(&config(
r#"
[[role]]
name = "reader"
tokens = ["t1"]
allow = ["msg:ls"]
[[role]]
name = "listener"
tokens = ["t2"]
allow = ["msg:sub"]
"#,
))
.unwrap();
let reader = acl.authenticate(Some("t1")).unwrap();
assert!(matches!(
reader.check(ACTION_SUBSCRIBE),
Err(AclError::Denied(_))
));
let listener = acl.authenticate(Some("t2")).unwrap();
assert!(listener.check(ACTION_SUBSCRIBE).is_ok());
}
#[test]
fn one_token_cannot_select_two_roles() {
let err = Acl::from_config(&config(
r#"
[[role]]
name = "a"
tokens = ["shared"]
allow = ["*"]
[[role]]
name = "b"
tokens = ["shared"]
allow = ["*"]
"#,
))
.unwrap_err();
assert!(err.to_string().contains("assigned to both"), "{err}");
}
#[test]
fn an_unknown_default_role_is_a_config_error() {
let err = Acl::from_config(&config(
r#"
default_role = "ghost"
[[role]]
name = "r"
tokens = ["t"]
allow = ["*"]
"#,
))
.unwrap_err();
assert!(err.to_string().contains("not a configured role"), "{err}");
}
#[test]
fn whoami_reports_the_effective_policy() {
let acl = operator_acl();
let value = acl.authenticate(Some("op-secret")).unwrap().to_value();
assert_eq!(value["subject"], "operator");
assert_eq!(value["unrestricted"], false);
assert_eq!(value["deny"][0], "proc:start_from_model");
assert_eq!(value["scopes"]["secrets"][0], "$subject");
let text = value.to_string();
assert!(!text.contains("op-secret"));
assert!(!text.contains(&hash_token("op-secret").unwrap()));
}
#[test]
fn anonymous_under_an_enabled_acl_is_denied_everything() {
let acl = operator_acl();
let anon = acl.anonymous();
assert!(anon.check("model:ls").is_err());
assert!(anon.check_scope("profile", "").is_err());
}
}