1use serde::{Deserialize, Serialize};
24
25use super::asked::Asked;
26use super::judgement::Judgement;
27
28#[derive(Debug, Clone, Default, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct Enrollment {
82 pub base: Option<String>,
85 #[serde(default)]
86 pub fleet: FleetSpec,
87 #[serde(default)]
88 pub principal: Vec<PrincipalSpec>,
89}
90
91#[derive(Debug, Clone, Default, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct FleetSpec {
96 #[serde(default)]
100 pub catalog_adv: bool,
101 pub salt: Option<String>,
104}
105
106#[derive(Debug, Clone, Default, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct PrincipalSpec {
110 pub cn: Option<String>,
113 pub zid: Option<String>,
115 pub id: Option<String>,
118 pub role: Role,
119 pub origin: Option<String>,
123 pub machine_id: Option<String>,
126 #[serde(default)]
128 pub adv: bool,
129 #[serde(default)]
131 pub blob_seed: bool,
132 #[serde(default)]
134 pub media: bool,
135 #[serde(default)]
139 pub remote_actions: bool,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
144#[serde(rename_all = "kebab-case")]
145pub enum Role {
146 #[default]
148 Host,
149 Catalog,
151 Console,
153 DesiredAuthor,
156 Watch,
160}
161
162impl Role {
163 pub fn as_str(self) -> &'static str {
164 match self {
165 Role::Host => "host",
166 Role::Catalog => "catalog",
167 Role::Console => "console",
168 Role::DesiredAuthor => "desired-author",
169 Role::Watch => "watch",
170 }
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum AclMessage {
180 Put,
181 Delete,
182 DeclareSubscriber,
183 Query,
184 DeclareQueryable,
185 Reply,
186 LivelinessToken,
187 DeclareLivelinessSubscriber,
188 LivelinessQuery,
189}
190
191impl AclMessage {
192 pub const ALL: [AclMessage; 9] = [
194 AclMessage::Put,
195 AclMessage::Delete,
196 AclMessage::DeclareSubscriber,
197 AclMessage::Query,
198 AclMessage::DeclareQueryable,
199 AclMessage::Reply,
200 AclMessage::LivelinessToken,
201 AclMessage::DeclareLivelinessSubscriber,
202 AclMessage::LivelinessQuery,
203 ];
204
205 pub fn as_str(self) -> &'static str {
206 match self {
207 AclMessage::Put => "put",
208 AclMessage::Delete => "delete",
209 AclMessage::DeclareSubscriber => "declare_subscriber",
210 AclMessage::Query => "query",
211 AclMessage::DeclareQueryable => "declare_queryable",
212 AclMessage::Reply => "reply",
213 AclMessage::LivelinessToken => "liveliness_token",
214 AclMessage::DeclareLivelinessSubscriber => "declare_liveliness_subscriber",
215 AclMessage::LivelinessQuery => "liveliness_query",
216 }
217 }
218
219 pub fn parse(token: &str) -> Option<AclMessage> {
221 AclMessage::ALL.into_iter().find(|m| m.as_str() == token)
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum AclFlow {
229 Egress,
230 Ingress,
231}
232
233impl AclFlow {
234 pub fn as_str(self) -> &'static str {
235 match self {
236 AclFlow::Egress => "egress",
237 AclFlow::Ingress => "ingress",
238 }
239 }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "snake_case")]
245pub enum AclPermission {
246 Allow,
247 Deny,
248}
249
250impl AclPermission {
251 pub fn as_str(self) -> &'static str {
252 match self {
253 AclPermission::Allow => "allow",
254 AclPermission::Deny => "deny",
255 }
256 }
257}
258
259#[derive(Debug, Clone, Serialize)]
264pub struct AclPlan {
265 pub base: String,
267 pub default_permission: AclPermission,
270 #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
274 pub registry: Asked<AclRegistryFacts>,
275 pub rules: Vec<AclRule>,
276 pub subjects: Vec<AclSubject>,
277 pub policies: Vec<AclPolicy>,
278 #[serde(skip_serializing_if = "Vec::is_empty")]
279 pub warnings: Vec<AclWarning>,
280 #[serde(skip_serializing_if = "Vec::is_empty")]
284 pub refusals: Vec<AclRefusal>,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
289pub struct AclRegistryFacts {
290 pub slices: usize,
291 pub media_producers: Vec<String>,
293 pub blob_producers: Vec<String>,
295 pub write_procedures: Vec<String>,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
301pub struct AclRule {
302 pub id: String,
303 pub permission: AclPermission,
304 #[serde(skip_serializing_if = "Option::is_none")]
306 pub flows: Option<Vec<AclFlow>>,
307 pub messages: Vec<AclMessage>,
308 pub key_exprs: Vec<String>,
309 pub purpose: String,
311 pub cite: String,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
317pub struct AclSubject {
318 pub id: String,
319 pub role: Role,
320 #[serde(skip_serializing_if = "Vec::is_empty")]
321 pub cert_common_names: Vec<String>,
322 #[serde(skip_serializing_if = "Vec::is_empty")]
324 pub zids: Vec<String>,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
329pub struct AclPolicy {
330 pub id: String,
331 pub rules: Vec<String>,
332 pub subjects: Vec<String>,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
337pub struct AclWarning {
338 pub kind: AclWarningKind,
339 #[serde(skip_serializing_if = "Option::is_none")]
340 pub principal: Option<String>,
341 pub text: String,
342 pub cite: String,
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
347#[serde(rename_all = "snake_case")]
348pub enum AclWarningKind {
349 WriteSetNotNarrowed,
352 NoWriteProcedures,
355 PlaneNotDeclared,
358 ZidSubject,
360 RoleAbsent,
363}
364
365impl AclWarningKind {
366 pub fn as_str(self) -> &'static str {
367 match self {
368 AclWarningKind::WriteSetNotNarrowed => "write_set_not_narrowed",
369 AclWarningKind::NoWriteProcedures => "no_write_procedures",
370 AclWarningKind::PlaneNotDeclared => "plane_not_declared",
371 AclWarningKind::ZidSubject => "zid_subject",
372 AclWarningKind::RoleAbsent => "role_absent",
373 }
374 }
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
379pub struct AclRefusal {
380 pub principal: String,
382 pub reason: String,
383 pub cite: String,
384}
385
386#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
401pub struct AclConfigDoc {
402 #[serde(default)]
403 pub enabled: bool,
404 #[serde(default = "deny")]
405 pub default_permission: String,
406 #[serde(default, deserialize_with = "null_as_empty")]
407 pub rules: Vec<AclRuleDoc>,
408 #[serde(default, deserialize_with = "null_as_empty")]
409 pub subjects: Vec<AclSubjectDoc>,
410 #[serde(default, deserialize_with = "null_as_empty")]
411 pub policies: Vec<AclPolicyDoc>,
412}
413
414fn deny() -> String {
415 "deny".to_string()
416}
417
418fn null_as_empty<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
422where
423 D: serde::Deserializer<'de>,
424 T: Default + Deserialize<'de>,
425{
426 Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
427}
428
429#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
431pub struct AclRuleDoc {
432 pub id: String,
433 #[serde(default, deserialize_with = "null_as_empty")]
434 pub key_exprs: Vec<String>,
435 #[serde(default, deserialize_with = "null_as_empty")]
436 pub messages: Vec<String>,
437 #[serde(default)]
438 pub flows: Option<Vec<String>>,
439 #[serde(default = "deny")]
440 pub permission: String,
441}
442
443#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
447pub struct AclSubjectDoc {
448 pub id: String,
449 #[serde(default)]
450 pub cert_common_names: Option<Vec<String>>,
451 #[serde(default)]
452 pub zids: Option<Vec<String>>,
453 #[serde(default)]
454 pub interfaces: Option<Vec<String>>,
455 #[serde(default)]
456 pub usernames: Option<Vec<String>>,
457 #[serde(default)]
458 pub link_protocols: Option<Vec<String>>,
459}
460
461#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
463pub struct AclPolicyDoc {
464 #[serde(default)]
465 pub id: Option<String>,
466 #[serde(default, deserialize_with = "null_as_empty")]
467 pub rules: Vec<String>,
468 #[serde(default, deserialize_with = "null_as_empty")]
469 pub subjects: Vec<String>,
470}
471
472#[derive(Debug, Clone, Serialize)]
477pub struct AclCheck {
478 pub base: String,
479 pub against: String,
481 pub planned_rules: usize,
482 pub observed_rules: usize,
483 pub planned_subjects: usize,
484 pub observed_subjects: usize,
485 pub findings: Vec<AclFinding>,
486 pub interest_probe: Judgement,
492 pub judgement: Judgement,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
497pub struct AclFinding {
498 pub kind: AclFindingKind,
499 pub id: String,
502 #[serde(skip_serializing_if = "Option::is_none")]
503 pub planned: Option<String>,
504 #[serde(skip_serializing_if = "Option::is_none")]
505 pub observed: Option<String>,
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
509#[serde(rename_all = "snake_case")]
510pub enum AclFindingKind {
511 Disabled,
513 DefaultPermissionDiffers,
514 RuleMissing,
516 RuleExtra,
518 RuleDiffers,
520 SubjectMissing,
521 SubjectExtra,
522 SubjectDiffers,
524 SubjectUnplannedProperty,
527 PolicyMissing,
529 PolicyExtra,
531 UnknownCn,
533}
534
535impl AclFindingKind {
536 pub fn as_str(self) -> &'static str {
537 match self {
538 AclFindingKind::Disabled => "disabled",
539 AclFindingKind::DefaultPermissionDiffers => "default_permission_differs",
540 AclFindingKind::RuleMissing => "rule_missing",
541 AclFindingKind::RuleExtra => "rule_extra",
542 AclFindingKind::RuleDiffers => "rule_differs",
543 AclFindingKind::SubjectMissing => "subject_missing",
544 AclFindingKind::SubjectExtra => "subject_extra",
545 AclFindingKind::SubjectDiffers => "subject_differs",
546 AclFindingKind::SubjectUnplannedProperty => "subject_unplanned_property",
547 AclFindingKind::PolicyMissing => "policy_missing",
548 AclFindingKind::PolicyExtra => "policy_extra",
549 AclFindingKind::UnknownCn => "unknown_cn",
550 }
551 }
552}
553
554#[derive(Debug, Clone, Serialize)]
559pub struct AclExplain {
560 pub principal: String,
561 pub key: String,
562 pub message: AclMessage,
563 pub base: String,
564 pub ingress: AclDirection,
567 pub egress: AclDirection,
568}
569
570#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
572pub struct AclDirection {
573 pub decision: AclDecision,
574 #[serde(skip_serializing_if = "Vec::is_empty")]
578 pub via: Vec<AclGrant>,
579 pub reason: String,
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
583#[serde(rename_all = "snake_case")]
584pub enum AclDecision {
585 Allowed,
586 Denied,
588 DeniedByDefault,
590}
591
592impl AclDecision {
593 pub fn as_str(self) -> &'static str {
594 match self {
595 AclDecision::Allowed => "allowed",
596 AclDecision::Denied => "denied",
597 AclDecision::DeniedByDefault => "denied by default",
598 }
599 }
600}
601
602#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
604pub struct AclGrant {
605 pub rule: String,
606 pub permission: AclPermission,
607 pub key_expr: String,
609 pub purpose: String,
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use serde_json::json;
616
617 #[test]
618 fn the_enrollment_file_parses_as_documented() {
619 let e: Enrollment = toml::from_str(
620 r#"
621base = "zensight"
622
623[fleet]
624catalog_adv = true
625salt = "example-salt-v1"
626
627[[principal]]
628cn = "h-3fa9c2d41b7e"
629role = "host"
630origin = "h-3fa9c2d41b7e"
631adv = true
632blob_seed = true
633media = true
634
635[[principal]]
636cn = "zensight-console"
637role = "console"
638remote_actions = true
639
640[[principal]]
641cn = "zensight-desired"
642role = "desired-author"
643origin = "@desired"
644"#,
645 )
646 .unwrap();
647 assert_eq!(e.base.as_deref(), Some("zensight"));
648 assert!(e.fleet.catalog_adv);
649 assert_eq!(e.fleet.salt.as_deref(), Some("example-salt-v1"));
650 assert_eq!(e.principal.len(), 3);
651 assert_eq!(e.principal[0].role, Role::Host);
652 assert!(e.principal[0].adv && e.principal[0].blob_seed && e.principal[0].media);
653 assert_eq!(e.principal[1].role, Role::Console);
654 assert!(e.principal[1].remote_actions);
655 assert_eq!(e.principal[2].role, Role::DesiredAuthor);
656 assert_eq!(e.principal[2].origin.as_deref(), Some("@desired"));
657
658 let bad = toml::from_str::<Enrollment>(
660 "[[principal]]\ncn = \"x\"\nrole = \"host\"\norigins = \"h-1\"\n",
661 );
662 assert!(bad.is_err());
663 let bad = toml::from_str::<Enrollment>("[[principal]]\ncn = \"x\"\nrole = \"operator\"\n");
664 assert!(bad.is_err());
665 }
666
667 #[test]
670 fn the_message_vocabulary_is_zenohs() {
671 let spelled: Vec<serde_json::Value> = AclMessage::ALL
672 .iter()
673 .map(|m| serde_json::to_value(m).unwrap())
674 .collect();
675 assert_eq!(
676 spelled,
677 vec![
678 json!("put"),
679 json!("delete"),
680 json!("declare_subscriber"),
681 json!("query"),
682 json!("declare_queryable"),
683 json!("reply"),
684 json!("liveliness_token"),
685 json!("declare_liveliness_subscriber"),
686 json!("liveliness_query"),
687 ]
688 );
689 for m in AclMessage::ALL {
690 assert_eq!(AclMessage::parse(m.as_str()), Some(m));
691 }
692 assert_eq!(AclMessage::parse("declare_publisher"), None);
693 assert_eq!(
694 serde_json::to_value(AclFlow::Egress).unwrap(),
695 json!("egress")
696 );
697 assert_eq!(
698 serde_json::to_value(AclPermission::Deny).unwrap(),
699 json!("deny")
700 );
701 }
702
703 #[test]
704 fn the_plan_pins_its_shape() {
705 let plan = AclPlan {
706 base: "zensight".into(),
707 default_permission: AclPermission::Deny,
708 registry: Asked::NotAsked,
709 rules: vec![AclRule {
710 id: "host-data-h-3fa9c2d41b7e".into(),
711 permission: AclPermission::Allow,
712 flows: Some(vec![AclFlow::Ingress]),
713 messages: vec![
714 AclMessage::Put,
715 AclMessage::Delete,
716 AclMessage::LivelinessToken,
717 ],
718 key_exprs: vec!["zensight/v1/h-3fa9c2d41b7e/**".into()],
719 purpose: "host-data".into(),
720 cite: "RFC 09 §3 fact 1".into(),
721 }],
722 subjects: vec![AclSubject {
723 id: "h-3fa9c2d41b7e".into(),
724 role: Role::Host,
725 cert_common_names: vec!["h-3fa9c2d41b7e".into()],
726 zids: vec![],
727 }],
728 policies: vec![AclPolicy {
729 id: "h-3fa9c2d41b7e".into(),
730 rules: vec!["host-data-h-3fa9c2d41b7e".into(), "interest-prop".into()],
731 subjects: vec!["h-3fa9c2d41b7e".into()],
732 }],
733 warnings: vec![AclWarning {
734 kind: AclWarningKind::WriteSetNotNarrowed,
735 principal: None,
736 text: "no registry asked".into(),
737 cite: "RFC 13 §3 O4".into(),
738 }],
739 refusals: vec![AclRefusal {
740 principal: "principal #2".into(),
741 reason: "a host needs origin or machine_id".into(),
742 cite: "RFC 03 §4 D6".into(),
743 }],
744 };
745 assert_eq!(
746 serde_json::to_value(&plan).unwrap(),
747 json!({
748 "base": "zensight",
749 "default_permission": "deny",
750 "rules": [{
751 "id": "host-data-h-3fa9c2d41b7e",
752 "permission": "allow",
753 "flows": ["ingress"],
754 "messages": ["put", "delete", "liveliness_token"],
755 "key_exprs": ["zensight/v1/h-3fa9c2d41b7e/**"],
756 "purpose": "host-data",
757 "cite": "RFC 09 §3 fact 1",
758 }],
759 "subjects": [{
760 "id": "h-3fa9c2d41b7e",
761 "role": "host",
762 "cert_common_names": ["h-3fa9c2d41b7e"],
763 }],
764 "policies": [{
765 "id": "h-3fa9c2d41b7e",
766 "rules": ["host-data-h-3fa9c2d41b7e", "interest-prop"],
767 "subjects": ["h-3fa9c2d41b7e"],
768 }],
769 "warnings": [{
770 "kind": "write_set_not_narrowed",
771 "text": "no registry asked",
772 "cite": "RFC 13 §3 O4",
773 }],
774 "refusals": [{
775 "principal": "principal #2",
776 "reason": "a host needs origin or machine_id",
777 "cite": "RFC 03 §4 D6",
778 }],
779 }),
780 "a not-asked registry is absent; a flowless rule omits `flows`; \
781 empty zids are absent"
782 );
783
784 let mut plan = plan;
786 plan.registry = Asked::Asked(AclRegistryFacts {
787 slices: 2,
788 media_producers: vec!["parallax".into()],
789 blob_producers: vec![],
790 write_procedures: vec!["systemd/action/set".into()],
791 });
792 plan.rules[0].flows = None;
793 plan.warnings.clear();
794 plan.refusals.clear();
795 let v = serde_json::to_value(&plan).unwrap();
796 assert_eq!(
797 v["registry"],
798 json!({
799 "slices": 2,
800 "media_producers": ["parallax"],
801 "blob_producers": [],
802 "write_procedures": ["systemd/action/set"],
803 })
804 );
805 assert!(v["rules"][0].get("flows").is_none());
806 assert!(v.get("warnings").is_none());
807 assert!(v.get("refusals").is_none());
808 }
809
810 #[test]
811 fn the_observed_block_parses_zenohs_shape() {
812 let doc: AclConfigDoc = serde_json::from_value(json!({
815 "enabled": true,
816 "default_permission": "deny",
817 "rules": [{
818 "id": "r1",
819 "key_exprs": ["zensight/v1/**"],
820 "messages": ["put"],
821 "permission": "allow",
822 }],
823 "subjects": [{ "id": "s1", "cert_common_names": ["h-1"] }],
824 "policies": [{ "rules": ["r1"], "subjects": ["s1"] }],
825 }))
826 .unwrap();
827 assert!(doc.enabled);
828 assert_eq!(doc.rules[0].flows, None);
829 let empty: AclConfigDoc = serde_json::from_value(json!({
832 "enabled": false, "default_permission": "deny",
833 "rules": null, "subjects": null, "policies": null,
834 }))
835 .unwrap();
836 assert!(empty.rules.is_empty() && empty.subjects.is_empty() && empty.policies.is_empty());
837 assert_eq!(doc.policies[0].id, None);
838 assert_eq!(
839 doc.subjects[0].cert_common_names.as_deref(),
840 Some(&["h-1".to_string()][..])
841 );
842 }
843
844 #[test]
845 fn the_check_pins_its_shape() {
846 let check = AclCheck {
847 base: "zensight".into(),
848 against: "router.json5".into(),
849 planned_rules: 3,
850 observed_rules: 2,
851 planned_subjects: 1,
852 observed_subjects: 1,
853 findings: vec![AclFinding {
854 kind: AclFindingKind::RuleMissing,
855 id: "interest-prop".into(),
856 planned: Some("egress declare_subscriber …".into()),
857 observed: None,
858 }],
859 interest_probe: Judgement::NotAsked,
860 judgement: Judgement::Established,
861 };
862 assert_eq!(
863 serde_json::to_value(&check).unwrap(),
864 json!({
865 "base": "zensight",
866 "against": "router.json5",
867 "planned_rules": 3,
868 "observed_rules": 2,
869 "planned_subjects": 1,
870 "observed_subjects": 1,
871 "findings": [{
872 "kind": "rule_missing",
873 "id": "interest-prop",
874 "planned": "egress declare_subscriber …",
875 }],
876 "interest_probe": { "answer": "not_asked" },
877 "judgement": { "answer": "established" },
878 })
879 );
880 }
881
882 #[test]
883 fn the_explain_pins_its_shape() {
884 let explain = AclExplain {
885 principal: "zensight-console".into(),
886 key: "zensight/v1/h-3fa9c2d41b7e/@rpc/systemd/action/set".into(),
887 message: AclMessage::Query,
888 base: "zensight".into(),
889 ingress: AclDirection {
890 decision: AclDecision::Denied,
891 via: vec![
892 AclGrant {
893 rule: "no-remote-actions".into(),
894 permission: AclPermission::Deny,
895 key_expr: "zensight/v1/*/@rpc/*/**/set".into(),
896 purpose: "no-remote-actions".into(),
897 },
898 AclGrant {
899 rule: "ops-sub".into(),
900 permission: AclPermission::Allow,
901 key_expr: "zensight/v1/*/@rpc/**".into(),
902 purpose: "ops-sub".into(),
903 },
904 ],
905 reason: "deny wins".into(),
906 },
907 egress: AclDirection {
908 decision: AclDecision::DeniedByDefault,
909 via: vec![],
910 reason: "no rule includes it".into(),
911 },
912 };
913 assert_eq!(
914 serde_json::to_value(&explain).unwrap(),
915 json!({
916 "principal": "zensight-console",
917 "key": "zensight/v1/h-3fa9c2d41b7e/@rpc/systemd/action/set",
918 "message": "query",
919 "base": "zensight",
920 "ingress": {
921 "decision": "denied",
922 "via": [
923 {
924 "rule": "no-remote-actions",
925 "permission": "deny",
926 "key_expr": "zensight/v1/*/@rpc/*/**/set",
927 "purpose": "no-remote-actions",
928 },
929 {
930 "rule": "ops-sub",
931 "permission": "allow",
932 "key_expr": "zensight/v1/*/@rpc/**",
933 "purpose": "ops-sub",
934 },
935 ],
936 "reason": "deny wins",
937 },
938 "egress": {
939 "decision": "denied_by_default",
940 "reason": "no rule includes it",
941 },
942 })
943 );
944 }
945}