use std::path::Path;
use serde_yaml::Value as YamlValue;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum GovernanceConfigError {
#[error("governance config is not valid YAML: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("governance config has no `governance.policies` sequence")]
MissingPolicies,
#[error("governance config policy entry {index} has no string `id`")]
MissingPolicyId { index: usize },
#[error("governance config exists but could not be read: {0}")]
Unreadable(#[from] std::io::Error),
#[error(
"governance config has an unknown mode `{value}` at {location}; expected `enforce` or `warn`"
)]
InvalidMode { location: String, value: String },
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum PolicyMode {
#[default]
Enforce,
Warn,
}
impl PolicyMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Enforce => "enforce",
Self::Warn => "warn",
}
}
#[must_use]
pub const fn is_warn(self) -> bool {
matches!(self, Self::Warn)
}
fn parse_str(value: &str) -> Option<Self> {
match value {
"enforce" => Some(Self::Enforce),
"warn" => Some(Self::Warn),
_ => None,
}
}
}
impl std::fmt::Display for PolicyMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
fn read_mode(
node: Option<&YamlValue>,
location: &str,
) -> Result<Option<PolicyMode>, GovernanceConfigError> {
let Some(raw) = node.and_then(|n| n.get("mode")) else {
return Ok(None);
};
let text = raw
.as_str()
.ok_or_else(|| GovernanceConfigError::InvalidMode {
location: location.to_owned(),
value: format!("{raw:?}"),
})?;
PolicyMode::parse_str(text)
.map(Some)
.ok_or_else(|| GovernanceConfigError::InvalidMode {
location: location.to_owned(),
value: text.to_owned(),
})
}
#[derive(Debug, Clone)]
pub struct PolicyConfig {
pub id: String,
pub enabled: bool,
pub mode: PolicyMode,
pub params: YamlValue,
}
#[derive(Debug, Clone)]
pub struct GovernanceConfig {
pub enabled: bool,
pub mode: PolicyMode,
pub policies: Vec<PolicyConfig>,
}
impl GovernanceConfig {
#[must_use]
pub fn defaults() -> Self {
let policies = ["secret_scan", "scope_check", "tool_blocklist", "rate_limit"]
.into_iter()
.map(|id| PolicyConfig {
id: id.to_owned(),
enabled: true,
mode: PolicyMode::Enforce,
params: YamlValue::Null,
})
.collect();
Self {
enabled: true,
mode: PolicyMode::Enforce,
policies,
}
}
pub fn parse(yaml: &str) -> Result<Self, GovernanceConfigError> {
let root: YamlValue = serde_yaml::from_str(yaml)?;
let governance = root.get("governance");
let enabled = governance
.and_then(|g| g.get("enabled"))
.and_then(YamlValue::as_bool)
.unwrap_or(true);
let default_mode = read_mode(governance, "governance")?.unwrap_or_default();
let policies = governance
.and_then(|g| g.get("policies"))
.and_then(YamlValue::as_sequence)
.ok_or(GovernanceConfigError::MissingPolicies)?;
let mut out = Vec::with_capacity(policies.len());
for (index, entry) in policies.iter().enumerate() {
let id = entry
.get("id")
.and_then(YamlValue::as_str)
.ok_or(GovernanceConfigError::MissingPolicyId { index })?
.to_owned();
let enabled = entry
.get("enabled")
.and_then(YamlValue::as_bool)
.unwrap_or(true);
let mode = read_mode(Some(entry), &format!("governance.policies[{index}] ({id})"))?
.unwrap_or(default_mode);
out.push(PolicyConfig {
id,
enabled,
mode,
params: entry.clone(),
});
}
Ok(Self {
enabled,
mode: default_mode,
policies: out,
})
}
fn read(path: &Path) -> Result<Option<Self>, GovernanceConfigError> {
match std::fs::read_to_string(path) {
Ok(text) => Self::parse(&text).map(Some),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(GovernanceConfigError::Unreadable(e)),
}
}
pub fn validate(path: &Path) -> Result<(), GovernanceConfigError> {
Self::read(path).map(|_| ())
}
#[must_use]
pub fn load(path: &Path) -> Self {
match Self::read(path) {
Ok(Some(config)) => config,
Ok(None) => {
tracing::warn!(
path = %path.display(),
"governance config not found; falling back to the built-in defaults, \
which enable every policy"
);
Self::defaults()
},
Err(error) => {
tracing::error!(
path = %path.display(),
%error,
"governance config rejected; falling back to the built-in defaults, \
which enable every policy and may not be what this file asked for"
);
Self::defaults()
},
}
}
}