use serde::{Deserialize, Serialize, Serializer};
use super::error::AuthzError;
use super::types::{Access, EntityKind};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AccessControlConfig {
#[serde(default)]
pub rules: Vec<RuleEntry>,
}
#[derive(Debug, Clone)]
pub enum RuleTarget {
Id(String),
Match(String),
}
#[derive(Debug, Clone)]
pub struct RuleEntry {
pub entity_type: EntityKind,
pub target: RuleTarget,
pub access: Access,
pub default_included: bool,
pub roles: Vec<String>,
pub justification: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RuleEntryWire {
entity_type: EntityKind,
#[serde(default)]
entity_id: Option<String>,
#[serde(default)]
entity_match: Option<String>,
#[serde(default = "default_allow")]
access: Access,
#[serde(default)]
default_included: bool,
#[serde(default)]
roles: Vec<String>,
#[serde(default)]
justification: Option<String>,
}
const fn default_allow() -> Access {
Access::Allow
}
#[derive(Serialize)]
struct RuleEntryOut<'a> {
entity_type: EntityKind,
#[serde(skip_serializing_if = "Option::is_none")]
entity_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
entity_match: Option<&'a str>,
access: Access,
default_included: bool,
roles: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
justification: Option<&'a str>,
}
impl Serialize for RuleEntry {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let (entity_id, entity_match) = match &self.target {
RuleTarget::Id(id) => (Some(id.as_str()), None),
RuleTarget::Match(pattern) => (None, Some(pattern.as_str())),
};
RuleEntryOut {
entity_type: self.entity_type,
entity_id,
entity_match,
access: self.access,
default_included: self.default_included,
roles: &self.roles,
justification: self.justification.as_deref(),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for RuleEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = RuleEntryWire::deserialize(deserializer)?;
let target = match (wire.entity_id, wire.entity_match) {
(Some(id), None) => RuleTarget::Id(id),
(None, Some(pattern)) => RuleTarget::Match(pattern),
(Some(_), Some(_)) => {
return Err(serde::de::Error::custom(format!(
"rule for entity_type={} sets both entity_id and entity_match; pick one",
wire.entity_type.as_str()
)));
},
(None, None) => {
return Err(serde::de::Error::custom(format!(
"rule for entity_type={} sets neither entity_id nor entity_match",
wire.entity_type.as_str()
)));
},
};
Ok(Self {
entity_type: wire.entity_type,
target,
access: wire.access,
default_included: wire.default_included,
roles: wire.roles,
justification: wire.justification,
})
}
}
impl AccessControlConfig {
pub fn validate(&self) -> Result<(), AuthzError> {
let mut problems: Vec<String> = Vec::new();
for (idx, rule) in self.rules.iter().enumerate() {
match &rule.target {
RuleTarget::Id(id) if id.trim().is_empty() => {
problems.push(format!("rules[{idx}]: entity_id is empty"));
},
RuleTarget::Match(pattern) if pattern.trim().is_empty() => {
problems.push(format!("rules[{idx}]: entity_match is empty"));
},
_ => {},
}
if rule.roles.is_empty() {
problems.push(format!(
"rules[{idx}]: must declare at least one role — per-user rules belong to \
runtime state, not YAML, and attribute-based rules belong in an extension \
hook"
));
}
for role in &rule.roles {
if role.trim().is_empty() {
problems.push(format!("rules[{idx}]: empty role string"));
}
}
}
if problems.is_empty() {
Ok(())
} else {
Err(AuthzError::Validation(problems.join("; ")))
}
}
}