use serde::{Deserialize, Serialize};
use super::asked::Asked;
use super::judgement::Judgement;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Enrollment {
pub base: Option<String>,
#[serde(default)]
pub fleet: FleetSpec,
#[serde(default)]
pub principal: Vec<PrincipalSpec>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FleetSpec {
#[serde(default)]
pub catalog_adv: bool,
pub salt: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrincipalSpec {
pub cn: Option<String>,
pub zid: Option<String>,
pub id: Option<String>,
pub role: Role,
pub origin: Option<String>,
pub machine_id: Option<String>,
#[serde(default)]
pub adv: bool,
#[serde(default)]
pub blob_seed: bool,
#[serde(default)]
pub media: bool,
#[serde(default)]
pub remote_actions: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Role {
#[default]
Host,
Catalog,
Console,
DesiredAuthor,
Watch,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::Host => "host",
Role::Catalog => "catalog",
Role::Console => "console",
Role::DesiredAuthor => "desired-author",
Role::Watch => "watch",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AclMessage {
Put,
Delete,
DeclareSubscriber,
Query,
DeclareQueryable,
Reply,
LivelinessToken,
DeclareLivelinessSubscriber,
LivelinessQuery,
}
impl AclMessage {
pub const ALL: [AclMessage; 9] = [
AclMessage::Put,
AclMessage::Delete,
AclMessage::DeclareSubscriber,
AclMessage::Query,
AclMessage::DeclareQueryable,
AclMessage::Reply,
AclMessage::LivelinessToken,
AclMessage::DeclareLivelinessSubscriber,
AclMessage::LivelinessQuery,
];
pub fn as_str(self) -> &'static str {
match self {
AclMessage::Put => "put",
AclMessage::Delete => "delete",
AclMessage::DeclareSubscriber => "declare_subscriber",
AclMessage::Query => "query",
AclMessage::DeclareQueryable => "declare_queryable",
AclMessage::Reply => "reply",
AclMessage::LivelinessToken => "liveliness_token",
AclMessage::DeclareLivelinessSubscriber => "declare_liveliness_subscriber",
AclMessage::LivelinessQuery => "liveliness_query",
}
}
pub fn parse(token: &str) -> Option<AclMessage> {
AclMessage::ALL.into_iter().find(|m| m.as_str() == token)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AclFlow {
Egress,
Ingress,
}
impl AclFlow {
pub fn as_str(self) -> &'static str {
match self {
AclFlow::Egress => "egress",
AclFlow::Ingress => "ingress",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AclPermission {
Allow,
Deny,
}
impl AclPermission {
pub fn as_str(self) -> &'static str {
match self {
AclPermission::Allow => "allow",
AclPermission::Deny => "deny",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AclPlan {
pub base: String,
pub default_permission: AclPermission,
#[serde(skip_serializing_if = "Asked::is_not_asked", default)]
pub registry: Asked<AclRegistryFacts>,
pub rules: Vec<AclRule>,
pub subjects: Vec<AclSubject>,
pub policies: Vec<AclPolicy>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<AclWarning>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub refusals: Vec<AclRefusal>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AclRegistryFacts {
pub slices: usize,
pub media_producers: Vec<String>,
pub blob_producers: Vec<String>,
pub write_procedures: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AclRule {
pub id: String,
pub permission: AclPermission,
#[serde(skip_serializing_if = "Option::is_none")]
pub flows: Option<Vec<AclFlow>>,
pub messages: Vec<AclMessage>,
pub key_exprs: Vec<String>,
pub purpose: String,
pub cite: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AclSubject {
pub id: String,
pub role: Role,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cert_common_names: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub zids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AclPolicy {
pub id: String,
pub rules: Vec<String>,
pub subjects: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AclWarning {
pub kind: AclWarningKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub principal: Option<String>,
pub text: String,
pub cite: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AclWarningKind {
WriteSetNotNarrowed,
NoWriteProcedures,
PlaneNotDeclared,
ZidSubject,
RoleAbsent,
}
impl AclWarningKind {
pub fn as_str(self) -> &'static str {
match self {
AclWarningKind::WriteSetNotNarrowed => "write_set_not_narrowed",
AclWarningKind::NoWriteProcedures => "no_write_procedures",
AclWarningKind::PlaneNotDeclared => "plane_not_declared",
AclWarningKind::ZidSubject => "zid_subject",
AclWarningKind::RoleAbsent => "role_absent",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AclRefusal {
pub principal: String,
pub reason: String,
pub cite: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AclConfigDoc {
#[serde(default)]
pub enabled: bool,
#[serde(default = "deny")]
pub default_permission: String,
#[serde(default, deserialize_with = "null_as_empty")]
pub rules: Vec<AclRuleDoc>,
#[serde(default, deserialize_with = "null_as_empty")]
pub subjects: Vec<AclSubjectDoc>,
#[serde(default, deserialize_with = "null_as_empty")]
pub policies: Vec<AclPolicyDoc>,
}
fn deny() -> String {
"deny".to_string()
}
fn null_as_empty<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: Default + Deserialize<'de>,
{
Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AclRuleDoc {
pub id: String,
#[serde(default, deserialize_with = "null_as_empty")]
pub key_exprs: Vec<String>,
#[serde(default, deserialize_with = "null_as_empty")]
pub messages: Vec<String>,
#[serde(default)]
pub flows: Option<Vec<String>>,
#[serde(default = "deny")]
pub permission: String,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AclSubjectDoc {
pub id: String,
#[serde(default)]
pub cert_common_names: Option<Vec<String>>,
#[serde(default)]
pub zids: Option<Vec<String>>,
#[serde(default)]
pub interfaces: Option<Vec<String>>,
#[serde(default)]
pub usernames: Option<Vec<String>>,
#[serde(default)]
pub link_protocols: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AclPolicyDoc {
#[serde(default)]
pub id: Option<String>,
#[serde(default, deserialize_with = "null_as_empty")]
pub rules: Vec<String>,
#[serde(default, deserialize_with = "null_as_empty")]
pub subjects: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AclCheck {
pub base: String,
pub against: String,
pub planned_rules: usize,
pub observed_rules: usize,
pub planned_subjects: usize,
pub observed_subjects: usize,
pub findings: Vec<AclFinding>,
pub interest_probe: Judgement,
pub judgement: Judgement,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AclFinding {
pub kind: AclFindingKind,
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub planned: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AclFindingKind {
Disabled,
DefaultPermissionDiffers,
RuleMissing,
RuleExtra,
RuleDiffers,
SubjectMissing,
SubjectExtra,
SubjectDiffers,
SubjectUnplannedProperty,
PolicyMissing,
PolicyExtra,
UnknownCn,
}
impl AclFindingKind {
pub fn as_str(self) -> &'static str {
match self {
AclFindingKind::Disabled => "disabled",
AclFindingKind::DefaultPermissionDiffers => "default_permission_differs",
AclFindingKind::RuleMissing => "rule_missing",
AclFindingKind::RuleExtra => "rule_extra",
AclFindingKind::RuleDiffers => "rule_differs",
AclFindingKind::SubjectMissing => "subject_missing",
AclFindingKind::SubjectExtra => "subject_extra",
AclFindingKind::SubjectDiffers => "subject_differs",
AclFindingKind::SubjectUnplannedProperty => "subject_unplanned_property",
AclFindingKind::PolicyMissing => "policy_missing",
AclFindingKind::PolicyExtra => "policy_extra",
AclFindingKind::UnknownCn => "unknown_cn",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AclExplain {
pub principal: String,
pub key: String,
pub message: AclMessage,
pub base: String,
pub ingress: AclDirection,
pub egress: AclDirection,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AclDirection {
pub decision: AclDecision,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub via: Vec<AclGrant>,
pub reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AclDecision {
Allowed,
Denied,
DeniedByDefault,
}
impl AclDecision {
pub fn as_str(self) -> &'static str {
match self {
AclDecision::Allowed => "allowed",
AclDecision::Denied => "denied",
AclDecision::DeniedByDefault => "denied by default",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AclGrant {
pub rule: String,
pub permission: AclPermission,
pub key_expr: String,
pub purpose: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_enrollment_file_parses_as_documented() {
let e: Enrollment = toml::from_str(
r#"
base = "zensight"
[fleet]
catalog_adv = true
salt = "example-salt-v1"
[[principal]]
cn = "h-3fa9c2d41b7e"
role = "host"
origin = "h-3fa9c2d41b7e"
adv = true
blob_seed = true
media = true
[[principal]]
cn = "zensight-console"
role = "console"
remote_actions = true
[[principal]]
cn = "zensight-desired"
role = "desired-author"
origin = "@desired"
"#,
)
.unwrap();
assert_eq!(e.base.as_deref(), Some("zensight"));
assert!(e.fleet.catalog_adv);
assert_eq!(e.fleet.salt.as_deref(), Some("example-salt-v1"));
assert_eq!(e.principal.len(), 3);
assert_eq!(e.principal[0].role, Role::Host);
assert!(e.principal[0].adv && e.principal[0].blob_seed && e.principal[0].media);
assert_eq!(e.principal[1].role, Role::Console);
assert!(e.principal[1].remote_actions);
assert_eq!(e.principal[2].role, Role::DesiredAuthor);
assert_eq!(e.principal[2].origin.as_deref(), Some("@desired"));
let bad = toml::from_str::<Enrollment>(
"[[principal]]\ncn = \"x\"\nrole = \"host\"\norigins = \"h-1\"\n",
);
assert!(bad.is_err());
let bad = toml::from_str::<Enrollment>("[[principal]]\ncn = \"x\"\nrole = \"operator\"\n");
assert!(bad.is_err());
}
#[test]
fn the_message_vocabulary_is_zenohs() {
let spelled: Vec<serde_json::Value> = AclMessage::ALL
.iter()
.map(|m| serde_json::to_value(m).unwrap())
.collect();
assert_eq!(
spelled,
vec![
json!("put"),
json!("delete"),
json!("declare_subscriber"),
json!("query"),
json!("declare_queryable"),
json!("reply"),
json!("liveliness_token"),
json!("declare_liveliness_subscriber"),
json!("liveliness_query"),
]
);
for m in AclMessage::ALL {
assert_eq!(AclMessage::parse(m.as_str()), Some(m));
}
assert_eq!(AclMessage::parse("declare_publisher"), None);
assert_eq!(
serde_json::to_value(AclFlow::Egress).unwrap(),
json!("egress")
);
assert_eq!(
serde_json::to_value(AclPermission::Deny).unwrap(),
json!("deny")
);
}
#[test]
fn the_plan_pins_its_shape() {
let plan = AclPlan {
base: "zensight".into(),
default_permission: AclPermission::Deny,
registry: Asked::NotAsked,
rules: vec![AclRule {
id: "host-data-h-3fa9c2d41b7e".into(),
permission: AclPermission::Allow,
flows: Some(vec![AclFlow::Ingress]),
messages: vec![
AclMessage::Put,
AclMessage::Delete,
AclMessage::LivelinessToken,
],
key_exprs: vec!["zensight/v1/h-3fa9c2d41b7e/**".into()],
purpose: "host-data".into(),
cite: "RFC 09 §3 fact 1".into(),
}],
subjects: vec![AclSubject {
id: "h-3fa9c2d41b7e".into(),
role: Role::Host,
cert_common_names: vec!["h-3fa9c2d41b7e".into()],
zids: vec![],
}],
policies: vec![AclPolicy {
id: "h-3fa9c2d41b7e".into(),
rules: vec!["host-data-h-3fa9c2d41b7e".into(), "interest-prop".into()],
subjects: vec!["h-3fa9c2d41b7e".into()],
}],
warnings: vec![AclWarning {
kind: AclWarningKind::WriteSetNotNarrowed,
principal: None,
text: "no registry asked".into(),
cite: "RFC 13 §3 O4".into(),
}],
refusals: vec![AclRefusal {
principal: "principal #2".into(),
reason: "a host needs origin or machine_id".into(),
cite: "RFC 03 §4 D6".into(),
}],
};
assert_eq!(
serde_json::to_value(&plan).unwrap(),
json!({
"base": "zensight",
"default_permission": "deny",
"rules": [{
"id": "host-data-h-3fa9c2d41b7e",
"permission": "allow",
"flows": ["ingress"],
"messages": ["put", "delete", "liveliness_token"],
"key_exprs": ["zensight/v1/h-3fa9c2d41b7e/**"],
"purpose": "host-data",
"cite": "RFC 09 §3 fact 1",
}],
"subjects": [{
"id": "h-3fa9c2d41b7e",
"role": "host",
"cert_common_names": ["h-3fa9c2d41b7e"],
}],
"policies": [{
"id": "h-3fa9c2d41b7e",
"rules": ["host-data-h-3fa9c2d41b7e", "interest-prop"],
"subjects": ["h-3fa9c2d41b7e"],
}],
"warnings": [{
"kind": "write_set_not_narrowed",
"text": "no registry asked",
"cite": "RFC 13 §3 O4",
}],
"refusals": [{
"principal": "principal #2",
"reason": "a host needs origin or machine_id",
"cite": "RFC 03 §4 D6",
}],
}),
"a not-asked registry is absent; a flowless rule omits `flows`; \
empty zids are absent"
);
let mut plan = plan;
plan.registry = Asked::Asked(AclRegistryFacts {
slices: 2,
media_producers: vec!["parallax".into()],
blob_producers: vec![],
write_procedures: vec!["systemd/action/set".into()],
});
plan.rules[0].flows = None;
plan.warnings.clear();
plan.refusals.clear();
let v = serde_json::to_value(&plan).unwrap();
assert_eq!(
v["registry"],
json!({
"slices": 2,
"media_producers": ["parallax"],
"blob_producers": [],
"write_procedures": ["systemd/action/set"],
})
);
assert!(v["rules"][0].get("flows").is_none());
assert!(v.get("warnings").is_none());
assert!(v.get("refusals").is_none());
}
#[test]
fn the_observed_block_parses_zenohs_shape() {
let doc: AclConfigDoc = serde_json::from_value(json!({
"enabled": true,
"default_permission": "deny",
"rules": [{
"id": "r1",
"key_exprs": ["zensight/v1/**"],
"messages": ["put"],
"permission": "allow",
}],
"subjects": [{ "id": "s1", "cert_common_names": ["h-1"] }],
"policies": [{ "rules": ["r1"], "subjects": ["s1"] }],
}))
.unwrap();
assert!(doc.enabled);
assert_eq!(doc.rules[0].flows, None);
let empty: AclConfigDoc = serde_json::from_value(json!({
"enabled": false, "default_permission": "deny",
"rules": null, "subjects": null, "policies": null,
}))
.unwrap();
assert!(empty.rules.is_empty() && empty.subjects.is_empty() && empty.policies.is_empty());
assert_eq!(doc.policies[0].id, None);
assert_eq!(
doc.subjects[0].cert_common_names.as_deref(),
Some(&["h-1".to_string()][..])
);
}
#[test]
fn the_check_pins_its_shape() {
let check = AclCheck {
base: "zensight".into(),
against: "router.json5".into(),
planned_rules: 3,
observed_rules: 2,
planned_subjects: 1,
observed_subjects: 1,
findings: vec![AclFinding {
kind: AclFindingKind::RuleMissing,
id: "interest-prop".into(),
planned: Some("egress declare_subscriber …".into()),
observed: None,
}],
interest_probe: Judgement::NotAsked,
judgement: Judgement::Established,
};
assert_eq!(
serde_json::to_value(&check).unwrap(),
json!({
"base": "zensight",
"against": "router.json5",
"planned_rules": 3,
"observed_rules": 2,
"planned_subjects": 1,
"observed_subjects": 1,
"findings": [{
"kind": "rule_missing",
"id": "interest-prop",
"planned": "egress declare_subscriber …",
}],
"interest_probe": { "answer": "not_asked" },
"judgement": { "answer": "established" },
})
);
}
#[test]
fn the_explain_pins_its_shape() {
let explain = AclExplain {
principal: "zensight-console".into(),
key: "zensight/v1/h-3fa9c2d41b7e/@rpc/systemd/action/set".into(),
message: AclMessage::Query,
base: "zensight".into(),
ingress: AclDirection {
decision: AclDecision::Denied,
via: vec![
AclGrant {
rule: "no-remote-actions".into(),
permission: AclPermission::Deny,
key_expr: "zensight/v1/*/@rpc/*/**/set".into(),
purpose: "no-remote-actions".into(),
},
AclGrant {
rule: "ops-sub".into(),
permission: AclPermission::Allow,
key_expr: "zensight/v1/*/@rpc/**".into(),
purpose: "ops-sub".into(),
},
],
reason: "deny wins".into(),
},
egress: AclDirection {
decision: AclDecision::DeniedByDefault,
via: vec![],
reason: "no rule includes it".into(),
},
};
assert_eq!(
serde_json::to_value(&explain).unwrap(),
json!({
"principal": "zensight-console",
"key": "zensight/v1/h-3fa9c2d41b7e/@rpc/systemd/action/set",
"message": "query",
"base": "zensight",
"ingress": {
"decision": "denied",
"via": [
{
"rule": "no-remote-actions",
"permission": "deny",
"key_expr": "zensight/v1/*/@rpc/*/**/set",
"purpose": "no-remote-actions",
},
{
"rule": "ops-sub",
"permission": "allow",
"key_expr": "zensight/v1/*/@rpc/**",
"purpose": "ops-sub",
},
],
"reason": "deny wins",
},
"egress": {
"decision": "denied_by_default",
"reason": "no rule includes it",
},
})
);
}
}