1use std::collections::{BTreeMap, BTreeSet};
48
49use zenoh::key_expr::keyexpr;
50
51use crate::model::registry::SliceSet;
52use crate::report::{
53 AclCheck, AclConfigDoc, AclDecision, AclDirection, AclExplain, AclFinding, AclFindingKind,
54 AclFlow, AclGrant, AclMessage, AclPermission, AclPlan, AclPolicy, AclRefusal, AclRegistryFacts,
55 AclRule, AclSubject, AclWarning, AclWarningKind, Asked, Enrollment, Judgement, PrincipalSpec,
56 Role,
57};
58use crate::{Error, Result};
59
60pub const INTEREST_PROP: &str = "interest-prop";
63pub const NO_REMOTE_ACTIONS: &str = "no-remote-actions";
66pub const UNNARROWED_WRITE_LEAF: &str = "v1/*/@rpc/*/**/set";
70
71#[derive(Debug, Clone, Copy, Default)]
73pub struct AclOptions {
74 pub allow_zid_subjects: bool,
77}
78
79const DECLARES: [AclMessage; 4] = [
82 AclMessage::DeclareSubscriber,
83 AclMessage::DeclareLivelinessSubscriber,
84 AclMessage::LivelinessQuery,
85 AclMessage::Query,
86];
87const RECEIVES: [AclMessage; 4] = [
88 AclMessage::Put,
89 AclMessage::Delete,
90 AclMessage::Reply,
91 AclMessage::LivelinessToken,
92];
93
94fn wire(base: &str, rel: impl AsRef<str>) -> String {
95 zenkey::grammar::with_base(base, rel)
96}
97
98struct Planes {
101 media: bool,
102 blob: bool,
103 catalog_adv: bool,
104}
105
106fn fleet_exprs(base: &str, p: &Planes) -> Vec<String> {
107 let mut out = vec![
108 wire(base, "v1/**"),
109 wire(base, "v1/@catalog/**"),
110 wire(base, "v1/*/@rpc/**"),
111 wire(base, "v1/@catalog/@rpc/**"),
112 ];
113 if p.blob {
114 out.push(wire(base, "v1/*/@blob/**"));
115 }
116 if p.media {
117 out.push(wire(base, "v1/*/@media/**"));
118 }
119 out.push(wire(base, "v1/**/@adv/**"));
120 if p.catalog_adv {
121 out.push(wire(base, "v1/@catalog/**/@adv/**"));
122 }
123 out
124}
125
126fn adv_exprs(base: &str, catalog_adv: bool) -> Vec<String> {
129 let mut out = vec![wire(base, "v1/**/@adv/**")];
130 if catalog_adv {
131 out.push(wire(base, "v1/@catalog/**/@adv/**"));
132 }
133 out
134}
135
136fn rule(
137 id: impl Into<String>,
138 permission: AclPermission,
139 flows: Option<&[AclFlow]>,
140 messages: &[AclMessage],
141 key_exprs: Vec<String>,
142 purpose: &str,
143 cite: &str,
144) -> AclRule {
145 AclRule {
146 id: id.into(),
147 permission,
148 flows: flows.map(<[AclFlow]>::to_vec),
149 messages: messages.to_vec(),
150 key_exprs,
151 purpose: purpose.into(),
152 cite: cite.into(),
153 }
154}
155
156const IN: &[AclFlow] = &[AclFlow::Ingress];
157const OUT: &[AclFlow] = &[AclFlow::Egress];
158
159fn id_chunk(origin: &str) -> &str {
161 origin.strip_prefix('@').unwrap_or(origin)
162}
163
164fn registry_facts(slices: &SliceSet) -> AclRegistryFacts {
167 let mut media_producers = Vec::new();
168 let mut blob_producers = Vec::new();
169 let mut write_procedures = Vec::new();
170 for slice in slices.slices() {
171 if slice.service_origin.is_none() {
172 if !slice.media.is_empty() {
173 media_producers.push(slice.name.clone());
174 }
175 if !slice.blob.is_empty() {
176 blob_producers.push(slice.name.clone());
177 }
178 }
179 for p in &slice.procedures {
180 if p.kind
181 .as_ref()
182 .is_some_and(|k| k.is(&zenkey::slice::ProcedureKind::Write))
183 {
184 write_procedures.push(format!("{}/{}", slice.name, p.path));
185 }
186 }
187 }
188 media_producers.sort();
189 blob_producers.sort();
190 write_procedures.sort();
191 AclRegistryFacts {
192 slices: slices.slices().len(),
193 media_producers,
194 blob_producers,
195 write_procedures,
196 }
197}
198
199fn procedure_pattern(path: &str) -> String {
201 path.split('/')
202 .map(|c| {
203 if c.starts_with('{') && c.ends_with('}') {
204 "*"
205 } else {
206 c
207 }
208 })
209 .collect::<Vec<_>>()
210 .join("/")
211}
212
213fn write_exprs(base: &str, slices: &SliceSet) -> Vec<String> {
215 let mut out = BTreeSet::new();
216 for slice in slices.slices() {
217 let origin = slice
218 .service_origin
219 .as_ref()
220 .map_or("*".to_string(), |o| o.token().to_string());
221 for p in &slice.procedures {
222 if p.kind
223 .as_ref()
224 .is_some_and(|k| k.is(&zenkey::slice::ProcedureKind::Write))
225 {
226 out.insert(wire(
227 base,
228 format!(
229 "v1/{origin}/@rpc/{}/{}",
230 slice.name,
231 procedure_pattern(&p.path)
232 ),
233 ));
234 }
235 }
236 }
237 out.into_iter().collect()
238}
239
240fn principal_name(index: usize, p: &PrincipalSpec) -> String {
244 p.id.clone()
245 .or_else(|| p.cn.clone())
246 .or_else(|| p.zid.clone())
247 .unwrap_or_else(|| format!("principal #{}", index + 1))
248}
249
250fn host_origin(
253 p: &PrincipalSpec,
254 salt: Option<&str>,
255) -> std::result::Result<String, (String, &'static str)> {
256 let given = p.origin.as_deref();
257 if let Some(o) = given
258 && !zenkey::grammar::is_valid_host_origin(o)
259 {
260 return Err((
261 format!("origin {o:?} is not an `h-<12 hex>` host origin"),
262 "RFC 06 §1",
263 ));
264 }
265 let Some(machine_id) = p.machine_id.as_deref() else {
266 return given.map(str::to_string).ok_or((
267 "a host needs `origin` or `machine_id` — nothing in the grammar binds a \
268 transport identity to an origin, so the enrollment has to"
269 .to_string(),
270 "RFC 03 §4 D6",
271 ));
272 };
273 let trimmed = machine_id.trim();
274 if trimmed.len() != 32 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
275 return Err((
276 format!("machine_id {machine_id:?} is not the 32 hex chars of /etc/machine-id"),
277 "RFC 06 §1",
278 ));
279 }
280 let Some(salt) = salt else {
281 return Err((
282 "machine_id needs the application's origin salt: set `[fleet] salt`".to_string(),
283 "RFC 06 §1",
284 ));
285 };
286 let salt: &'static str = Box::leak(salt.to_string().into_boxed_str());
291 let computed = zenkey::origin::HostId::from_machine_id(trimmed, zenkey::OriginSalt::new(salt));
292 let computed = computed.as_str().to_string();
293 match given {
294 Some(o) if o != computed => Err((
295 format!(
296 "origin {o:?} disagrees with the origin computed from machine_id under this \
297 salt, {computed:?} — one of the three is wrong, and a rule pinned to the \
298 wrong origin fails silently"
299 ),
300 "RFC 06 §1",
301 )),
302 _ => Ok(computed),
303 }
304}
305
306fn service_origin(
309 p: &PrincipalSpec,
310 default: &str,
311) -> std::result::Result<String, (String, &'static str)> {
312 match p.origin.as_deref() {
313 None => Ok(default.to_string()),
314 Some(o) if zenkey::grammar::is_valid_verbatim_chunk(o) => Ok(o.to_string()),
315 Some(o) => Err((
316 format!("origin {o:?} is not a verbatim `@…` service origin"),
317 "RFC 03 §1.2",
318 )),
319 }
320}
321
322fn warn(
323 kind: AclWarningKind,
324 principal: Option<&str>,
325 cite: &str,
326 text: impl Into<String>,
327) -> AclWarning {
328 AclWarning {
329 kind,
330 principal: principal.map(str::to_string),
331 text: text.into(),
332 cite: cite.into(),
333 }
334}
335
336#[derive(Default)]
339struct Rules {
340 order: Vec<String>,
341 by_id: BTreeMap<String, AclRule>,
342}
343
344impl Rules {
345 fn push(&mut self, r: AclRule) -> String {
346 let id = r.id.clone();
347 if !self.by_id.contains_key(&id) {
348 self.order.push(id.clone());
349 self.by_id.insert(id.clone(), r);
350 }
351 id
352 }
353
354 fn into_vec(self) -> Vec<AclRule> {
355 let mut by_id = self.by_id;
356 self.order
357 .into_iter()
358 .filter_map(|id| by_id.remove(&id))
359 .collect()
360 }
361}
362
363pub fn plan_acl(
377 enrollment: &Enrollment,
378 base: &str,
379 slices: Option<&SliceSet>,
380 opts: AclOptions,
381) -> AclPlan {
382 let mut rules = Rules::default();
383 let mut subjects = Vec::new();
384 let mut policies = Vec::new();
385 let mut warnings = Vec::new();
386 let mut refusals = Vec::new();
387
388 let facts = slices.map(registry_facts);
389 let catalog_adv = enrollment.fleet.catalog_adv;
390 let salt = enrollment.fleet.salt.as_deref();
391
392 let claimed_media = enrollment
395 .principal
396 .iter()
397 .any(|p| p.role == Role::Host && p.media);
398 let planes = match &facts {
399 Some(f) => Planes {
400 media: !f.media_producers.is_empty(),
401 blob: !f.blob_producers.is_empty(),
404 catalog_adv,
405 },
406 None => Planes {
407 media: claimed_media,
408 blob: true,
411 catalog_adv,
412 },
413 };
414
415 let write_set: Vec<String> = match slices {
417 Some(s) => {
418 let exprs = write_exprs(base, s);
419 if exprs.is_empty() {
420 warnings.push(warn(
421 AclWarningKind::NoWriteProcedures,
422 None,
423 "RFC 09 §3",
424 "the registry declares no `kind = \"write\"` procedure, so the \
425 no-remote-actions deny is omitted — zenoh refuses a rule with no \
426 key expression",
427 ));
428 }
429 exprs
430 }
431 None => {
432 warnings.push(warn(
433 AclWarningKind::WriteSetNotNarrowed,
434 None,
435 "RFC 13 §3 O4",
436 format!(
437 "no registry asked: the planes are as the enrollment claims, and \
438 no-remote-actions denies the convention's write leaf {} rather than \
439 the declared write procedures — pass --registry <dir> to narrow both \
440 to what the fleet actually declares",
441 wire(base, UNNARROWED_WRITE_LEAF)
442 ),
443 ));
444 vec![wire(base, UNNARROWED_WRITE_LEAF)]
445 }
446 };
447
448 let interest_prop = || {
449 rule(
450 INTEREST_PROP,
451 AclPermission::Allow,
452 Some(OUT),
453 &DECLARES,
454 fleet_exprs(base, &planes),
455 "interest-prop",
456 "the fifth fact (reference deployment 2026-08-30): zenoh checks a consumer's \
457 declares and queries on egress toward the responding face against that \
458 face's subject, and own-origin grants include no wildcard-origin selector \
459 — without this a peer-mode publisher publishes to nobody. Egress-only, \
460 declares and queries only: puts and replies stay ingress-checked",
461 )
462 };
463
464 let mut seen_ids = BTreeSet::new();
465 let mut seen_cns = BTreeSet::new();
466 let mut seen_zids = BTreeSet::new();
467 let mut hosts = 0usize;
468 let mut consumers = 0usize;
469
470 for (i, p) in enrollment.principal.iter().enumerate() {
471 let name = principal_name(i, p);
472 let mut refuse = |reason: String, cite: &str| {
473 refusals.push(AclRefusal {
474 principal: name.clone(),
475 reason,
476 cite: cite.into(),
477 });
478 };
479
480 let cn = p.cn.as_deref().filter(|s| !s.is_empty());
482 let zid = p.zid.as_deref().filter(|s| !s.is_empty());
483 if cn.is_none() && zid.is_none() {
484 refuse(
485 "a principal needs a `cn` (the certificate common name) — that is the \
486 enrollment"
487 .into(),
488 "RFC 03 §4 D6",
489 );
490 continue;
491 }
492 if let Some(z) = zid
493 && !opts.allow_zid_subjects
494 {
495 refuse(
496 format!(
497 "zid {z:?} as a subject is refused: a ZID is not backed by an \
498 authentication mechanism and can only be trusted for ACL when a \
499 dedicated zenoh mechanism manages it (zenoh's own config says so); \
500 pass --allow-zid-subjects for a prototype, never a deployment"
501 ),
502 "zenoh-1.10.0/DEFAULT_CONFIG.json5",
503 );
504 continue;
505 }
506 if let Some(c) = cn
507 && !seen_cns.insert(c.to_string())
508 {
509 refuse(
510 format!("cn {c:?} is already enrolled — one transport identity, one principal"),
511 "RFC 03 §4 D6",
512 );
513 continue;
514 }
515 if let Some(z) = zid
516 && !seen_zids.insert(z.to_string())
517 {
518 refuse(format!("zid {z:?} is already enrolled"), "RFC 03 §4 D6");
519 continue;
520 }
521 let sid =
522 p.id.clone()
523 .or_else(|| cn.map(str::to_string))
524 .or_else(|| zid.map(str::to_string))
525 .expect("cn or zid is present");
526 if !seen_ids.insert(sid.clone()) {
527 refuse(
528 format!("subject id {sid:?} is already taken — give this principal an `id`"),
529 "zenoh-config: subject ids are unique",
530 );
531 continue;
532 }
533 if let Some(z) = zid {
534 warnings.push(warn(
535 AclWarningKind::ZidSubject,
536 Some(&sid),
537 "zenoh-1.10.0/DEFAULT_CONFIG.json5",
538 format!(
539 "subject {sid:?} is bound by zid {z:?}: prototyping only — a ZID is not \
540 backed by authentication and a peer can present any"
541 ),
542 ));
543 }
544 if p.remote_actions && p.role == Role::Watch {
545 refuse(
546 "a watch is read-only by definition; `remote_actions = true` wants a \
547 console"
548 .into(),
549 "RFC 09 §3",
550 );
551 continue;
552 }
553
554 let mut ids: Vec<String> = Vec::new();
556 match p.role {
557 Role::Host => {
558 let origin = match host_origin(p, salt) {
559 Ok(o) => o,
560 Err((reason, cite)) => {
561 refuse(reason, cite);
562 continue;
563 }
564 };
565 hosts += 1;
566 let o = origin.as_str();
567 ids.push(rules.push(rule(
568 format!("host-data-{o}"),
569 AclPermission::Allow,
570 Some(IN),
571 &[
572 AclMessage::Put,
573 AclMessage::Delete,
574 AclMessage::LivelinessToken,
575 ],
576 vec![wire(base, format!("v1/{o}/**"))],
577 "host-data",
578 "RFC 09 §3 fact 1: the data classes and the alive tokens — and nothing \
579 under @rpc, @media or @blob, which `**` never reaches",
580 )));
581 let mut serve = vec![wire(base, format!("v1/{o}/@rpc/**"))];
583 if planes.blob {
584 serve.push(wire(base, format!("v1/{o}/@blob/**")));
585 }
586 ids.push(rules.push(rule(
587 format!("host-serve-{o}"),
588 AclPermission::Allow,
589 None,
590 &[
591 AclMessage::DeclareQueryable,
592 AclMessage::Reply,
593 AclMessage::Query,
594 ],
595 serve,
596 "host-serve",
597 "RFC 09 §3 facts 1 and 4: @rpc spelled explicitly because `**` will not \
598 cross it; declare_queryable allowed or the host serves nothing; query \
599 egress is the router forwarding calls to it",
600 )));
601 if p.media {
602 if planes.media {
603 ids.push(rules.push(rule(
604 format!("host-media-{o}"),
605 AclPermission::Allow,
606 Some(IN),
607 &[AclMessage::Put],
608 vec![wire(base, format!("v1/{o}/@media/**"))],
609 "host-media",
610 "RFC 09 §3 fact 1: the plane needs its own rule",
611 )));
612 } else {
613 warnings.push(warn(
614 AclWarningKind::PlaneNotDeclared,
615 Some(&sid),
616 "RFC 08 §2",
617 "media = true, but no host producer in the registry declares \
618 [[media]] — host-media omitted; the enrollment claims a plane \
619 the fleet does not serve",
620 ));
621 }
622 }
623 if p.blob_seed {
624 if planes.blob {
625 ids.push(rules.push(rule(
626 format!("host-blob-seed-{o}"),
627 AclPermission::Allow,
628 Some(IN),
629 &[AclMessage::Put],
630 vec![
631 wire(base, format!("v1/{o}/@blob/store/**")),
632 wire(base, format!("v1/{o}/@blob/tree/**")),
633 ],
634 "host-blob-seed",
635 "RFC 09 §3, RFC 07 §2: the sanctioned one-shot PUT path into the \
636 router content store — omit in deployments without one",
637 )));
638 } else {
639 warnings.push(warn(
640 AclWarningKind::PlaneNotDeclared,
641 Some(&sid),
642 "RFC 08 §2",
643 "blob_seed = true, but no host producer in the registry declares \
644 [[blob]] — host-blob-seed omitted",
645 ));
646 }
647 }
648 if p.adv {
649 ids.push(rules.push(rule(
650 format!("host-adv-{o}"),
651 AclPermission::Allow,
652 None,
653 &[
654 AclMessage::Put,
655 AclMessage::LivelinessToken,
656 AclMessage::DeclareQueryable,
657 AclMessage::Reply,
658 AclMessage::Query,
659 ],
660 vec![wire(base, format!("v1/{o}/**/@adv/**"))],
661 "host-adv",
662 "RFC 09 §3, RFC 04 §3.3: the sidecars live under a verbatim @adv \
663 suffix host-data's `**` cannot reach — omitting this when the tier \
664 is in use fails silently: empty seeds, dead recovery",
665 )));
666 }
667 ids.push(rules.push(interest_prop()));
668 }
669 Role::Catalog => {
670 let origin = match service_origin(p, zenkey::grammar::SERVICE_CATALOG) {
671 Ok(o) => o,
672 Err((reason, cite)) => {
673 refuse(reason, cite);
674 continue;
675 }
676 };
677 consumers += 1;
678 let o = origin.as_str();
679 let c = id_chunk(o);
680 let mut own = vec![
681 wire(base, format!("v1/{o}/**")),
682 wire(base, format!("v1/{o}/@rpc/**")),
683 ];
684 if catalog_adv {
685 own.push(wire(base, format!("v1/{o}/**/@adv/**")));
686 }
687 ids.push(rules.push(rule(
688 format!("catalog-own-{c}"),
689 AclPermission::Allow,
690 None,
691 &[
692 AclMessage::Put,
693 AclMessage::Delete,
694 AclMessage::LivelinessToken,
695 AclMessage::DeclareQueryable,
696 AclMessage::Reply,
697 AclMessage::Query,
698 ],
699 own,
700 "catalog-own",
701 "RFC 09 §3 fact 2: `*` never covers a verbatim origin, so the catalog's \
702 own keys are their own rule — @rpc and, on the advanced tier, @adv \
703 spelled beside them (fact 1)",
704 )));
705 let intake = if catalog_adv {
706 vec![wire(base, "v1/**"), wire(base, "v1/**/@adv/**")]
707 } else {
708 vec![wire(base, "v1/**")]
709 };
710 ids.push(rules.push(rule(
711 format!("catalog-intake-declare-{c}"),
712 AclPermission::Allow,
713 Some(IN),
714 &DECLARES,
715 intake.clone(),
716 "catalog-intake-declare",
717 "RFC 09 §3 fact 4: the catalog DECLARES interest on ingress; split by flow \
718 from the receive half so it cannot ingress-publish any host's keys",
719 )));
720 ids.push(rules.push(rule(
721 format!("catalog-intake-recv-{c}"),
722 AclPermission::Allow,
723 Some(OUT),
724 &RECEIVES,
725 intake,
726 "catalog-intake-recv",
727 "RFC 09 §3: the catalog RECEIVES data and tokens on egress — a flowless \
728 rule here would let it tombstone any host's keys, defeating the \
729 per-host enrollment",
730 )));
731 ids.push(rules.push(interest_prop()));
732 }
733 Role::Console => {
734 consumers += 1;
735 ids.push(rules.push(rule(
736 "ops-sub",
737 AclPermission::Allow,
738 Some(IN),
739 &DECLARES,
740 fleet_exprs(base, &planes),
741 "ops-sub",
742 "RFC 09 §3 fact 4: every plane named, because a declare that is not \
743 allowed receives nothing and `**` reaches no verbatim plane (fact 1)",
744 )));
745 ids.push(rules.push(rule(
746 "ops-recv",
747 AclPermission::Allow,
748 Some(OUT),
749 &RECEIVES,
750 fleet_exprs(base, &planes),
751 "ops-recv",
752 "RFC 09 §3: what the console receives, on egress, over the same planes",
753 )));
754 if p.adv {
755 ids.push(rules.push(rule(
756 "ops-own-token",
757 AclPermission::Allow,
758 Some(IN),
759 &[AclMessage::LivelinessToken],
760 adv_exprs(base, catalog_adv),
761 "ops-own-token",
762 "RFC 09 §3: the console's own subscriber-detection token, confined to \
763 @adv — a broad ingress liveliness_token allow would let it forge \
764 any host's state/*/alive roster entry",
765 )));
766 }
767 if !p.remote_actions && !write_set.is_empty() {
768 ids.push(rules.push(rule(
769 NO_REMOTE_ACTIONS,
770 AclPermission::Deny,
771 None,
772 &[AclMessage::Query],
773 write_set.clone(),
774 "no-remote-actions",
775 "RFC 09 §3: the write procedures are deniable per key because the key \
776 IS the target; deny wins, and is sound under default-deny",
777 )));
778 }
779 }
780 Role::Watch => {
781 consumers += 1;
782 let read_only = Planes {
783 media: false,
784 blob: false,
785 catalog_adv,
786 };
787 ids.push(rules.push(rule(
788 "watch-sub",
789 AclPermission::Allow,
790 Some(IN),
791 &DECLARES,
792 fleet_exprs(base, &read_only),
793 "watch-sub",
794 "RFC 09 §3 fact 4, for a read-only observer: the data classes, the \
795 catalog and RPC reads — never @media, never @blob",
796 )));
797 ids.push(rules.push(rule(
798 "watch-recv",
799 AclPermission::Allow,
800 Some(OUT),
801 &RECEIVES,
802 fleet_exprs(base, &read_only),
803 "watch-recv",
804 "RFC 09 §3: what a read-only observer receives, on egress",
805 )));
806 if p.adv {
807 ids.push(rules.push(rule(
808 "ops-own-token",
809 AclPermission::Allow,
810 Some(IN),
811 &[AclMessage::LivelinessToken],
812 adv_exprs(base, catalog_adv),
813 "ops-own-token",
814 "RFC 09 §3: the observer's own subscriber-detection token, confined \
815 to @adv",
816 )));
817 }
818 if !write_set.is_empty() {
819 ids.push(rules.push(rule(
820 NO_REMOTE_ACTIONS,
821 AclPermission::Deny,
822 None,
823 &[AclMessage::Query],
824 write_set.clone(),
825 "no-remote-actions",
826 "RFC 09 §3: the write procedures are deniable per key because the key \
827 IS the target; deny wins, and is sound under default-deny",
828 )));
829 }
830 }
831 Role::DesiredAuthor => {
832 let origin = match service_origin(p, "@desired") {
833 Ok(o) => o,
834 Err((reason, cite)) => {
835 refuse(reason, cite);
836 continue;
837 }
838 };
839 let o = origin.as_str();
840 ids.push(rules.push(rule(
841 format!("desired-author-{}", id_chunk(o)),
842 AclPermission::Allow,
843 Some(IN),
844 &[AclMessage::Put, AclMessage::Delete],
845 vec![wire(base, format!("v1/{o}/state/**"))],
846 "desired-author",
847 "RFC 09 §3, RFC 07 §3: exactly one ingress put/delete grant, on its OWN \
848 service-origin subtree — never on a host origin; the target host id is \
849 the first subject chunk, not the origin, so it cannot forge a host's \
850 state",
851 )));
852 ids.push(rules.push(interest_prop()));
853 }
854 }
855
856 subjects.push(AclSubject {
857 id: sid.clone(),
858 role: p.role,
859 cert_common_names: cn.map(|c| vec![c.to_string()]).unwrap_or_default(),
860 zids: zid.map(|z| vec![z.to_string()]).unwrap_or_default(),
861 });
862 policies.push(AclPolicy {
863 id: sid.clone(),
864 rules: ids,
865 subjects: vec![sid],
866 });
867 }
868
869 if hosts == 0 {
870 warnings.push(warn(
871 AclWarningKind::RoleAbsent,
872 None,
873 "RFC 09 §3",
874 "no host enrolled: nothing in this fleet may publish an origin",
875 ));
876 }
877 if consumers == 0 {
878 warnings.push(warn(
879 AclWarningKind::RoleAbsent,
880 None,
881 "RFC 09 §3 fact 4",
882 "no catalog, console or watch enrolled: nothing may declare interest, so \
883 every host publishes to nobody",
884 ));
885 }
886
887 AclPlan {
888 base: base.to_string(),
889 default_permission: AclPermission::Deny,
890 registry: facts.map_or(Asked::NotAsked, Asked::Asked),
891 rules: rules.into_vec(),
892 subjects,
893 policies,
894 warnings,
895 refusals,
896 }
897}
898
899fn js(s: &str) -> String {
902 serde_json::to_string(s).expect("a string serializes")
903}
904
905fn js_list<'a>(items: impl IntoIterator<Item = &'a str>) -> String {
906 let inner: Vec<String> = items.into_iter().map(js).collect();
907 format!("[{}]", inner.join(", "))
908}
909
910pub fn to_json5(plan: &AclPlan) -> String {
919 use std::fmt::Write as _;
920 let mut out = String::new();
921 let _ = writeln!(
922 out,
923 "// zenohd access_control block — generated by `zenctl acl gen` (RFC 09 §3)."
924 );
925 let _ = write!(
926 out,
927 "// base {}; {} subject(s), {} rule(s), {} polic(y/ies); ",
928 js(&plan.base),
929 plan.subjects.len(),
930 plan.rules.len(),
931 plan.policies.len()
932 );
933 match plan.registry.as_option() {
934 Some(r) => {
935 let _ = writeln!(
936 out,
937 "registry: {} slice(s), {} write procedure(s), media by [{}], blob by [{}].",
938 r.slices,
939 r.write_procedures.len(),
940 r.media_producers.join(", "),
941 r.blob_producers.join(", ")
942 );
943 }
944 None => {
945 let _ = writeln!(
946 out,
947 "registry: not asked — planes are as the enrollment claims and the write \
948 set is the convention's `set` leaf, unnarrowed."
949 );
950 }
951 }
952 let _ = writeln!(
953 out,
954 "// Field names: zenoh 1.10 (zenoh-config-1.10.0/src/lib.rs — AclConfig, AclConfigRule,\n\
955 // AclMessage, InterceptorFlow, AclConfigSubjects, AclConfigPolicyEntry). A rule with no\n\
956 // `flows` applies in both directions. Merge at the router config's top level; the\n\
957 // three lists are all required — rules alone are refused at startup (fact 3). ACL\n\
958 // config is not runtime-reloadable: enrolling a host is a router restart (RFC 03 §4 D6)."
959 );
960 for r in &plan.refusals {
961 let _ = writeln!(out, "// REFUSED {}: {} ({})", r.principal, r.reason, r.cite);
962 }
963 for w in &plan.warnings {
964 let who = w
965 .principal
966 .as_deref()
967 .map_or(String::new(), |p| format!(" [{p}]"));
968 let _ = writeln!(
969 out,
970 "// ! {}{who}: {} ({})",
971 w.kind.as_str(),
972 w.text,
973 w.cite
974 );
975 }
976 let _ = writeln!(out, "access_control: {{");
977 let _ = writeln!(out, " enabled: true,");
978 let _ = writeln!(
979 out,
980 " default_permission: {}, // fact 4: deny, and every declaration allowed by name",
981 js(plan.default_permission.as_str())
982 );
983
984 let _ = writeln!(out, " rules: [");
985 for r in &plan.rules {
986 let _ = writeln!(out, " // {}: {}", r.purpose, r.cite);
987 let _ = write!(
988 out,
989 " {{ id: {}, permission: {}",
990 js(&r.id),
991 js(r.permission.as_str())
992 );
993 if let Some(flows) = &r.flows {
994 let _ = write!(
995 out,
996 ", flows: {}",
997 js_list(flows.iter().map(|f| f.as_str()))
998 );
999 }
1000 let _ = writeln!(out, ",");
1001 let _ = writeln!(
1002 out,
1003 " messages: {},",
1004 js_list(r.messages.iter().map(|m| m.as_str()))
1005 );
1006 let _ = writeln!(
1007 out,
1008 " key_exprs: {} }},",
1009 js_list(r.key_exprs.iter().map(String::as_str))
1010 );
1011 }
1012 let _ = writeln!(out, " ],");
1013
1014 let _ = writeln!(
1015 out,
1016 " subjects: [ // the enrollment: transport identity ↔ origin (RFC 03 §4 D6)"
1017 );
1018 for s in &plan.subjects {
1019 let _ = write!(out, " {{ id: {}", js(&s.id));
1020 if !s.cert_common_names.is_empty() {
1021 let _ = write!(
1022 out,
1023 ", cert_common_names: {}",
1024 js_list(s.cert_common_names.iter().map(String::as_str))
1025 );
1026 }
1027 if !s.zids.is_empty() {
1028 let _ = write!(
1029 out,
1030 ", zids: {}",
1031 js_list(s.zids.iter().map(String::as_str))
1032 );
1033 }
1034 let _ = writeln!(out, " }}, // {}", s.role.as_str());
1035 }
1036 let _ = writeln!(out, " ],");
1037
1038 let _ = writeln!(out, " policies: [");
1039 for p in &plan.policies {
1040 let _ = writeln!(
1041 out,
1042 " {{ id: {}, rules: {},\n subjects: {} }},",
1043 js(&p.id),
1044 js_list(p.rules.iter().map(String::as_str)),
1045 js_list(p.subjects.iter().map(String::as_str))
1046 );
1047 }
1048 let _ = writeln!(out, " ],");
1049 let _ = writeln!(out, "}}");
1050 out
1051}
1052
1053fn set<'a>(items: impl IntoIterator<Item = &'a str>) -> BTreeSet<String> {
1056 items.into_iter().map(str::to_string).collect()
1057}
1058
1059fn rule_shape(
1061 permission: &str,
1062 flows: Option<&[String]>,
1063 messages: &[String],
1064 key_exprs: &[String],
1065) -> String {
1066 let flows: BTreeSet<&str> = match flows {
1067 Some(f) => f.iter().map(String::as_str).collect(),
1068 None => ["egress", "ingress"].into_iter().collect(),
1069 };
1070 let messages: BTreeSet<&str> = messages.iter().map(String::as_str).collect();
1071 let key_exprs: BTreeSet<&str> = key_exprs.iter().map(String::as_str).collect();
1072 format!(
1073 "{permission} {} {} {}",
1074 flows.into_iter().collect::<Vec<_>>().join("+"),
1075 messages.into_iter().collect::<Vec<_>>().join(","),
1076 key_exprs.into_iter().collect::<Vec<_>>().join(" ")
1077 )
1078}
1079
1080fn planned_rule_shape(r: &AclRule) -> String {
1081 let flows: Option<Vec<String>> = r
1082 .flows
1083 .as_ref()
1084 .map(|f| f.iter().map(|x| x.as_str().to_string()).collect());
1085 let messages: Vec<String> = r.messages.iter().map(|m| m.as_str().to_string()).collect();
1086 rule_shape(
1087 r.permission.as_str(),
1088 flows.as_deref(),
1089 &messages,
1090 &r.key_exprs,
1091 )
1092}
1093
1094pub fn check_acl(plan: &AclPlan, observed: &AclConfigDoc, against: &str) -> AclCheck {
1101 let mut findings = Vec::new();
1102 let mut finding = |kind, id: &str, planned: Option<String>, observed: Option<String>| {
1103 findings.push(AclFinding {
1104 kind,
1105 id: id.to_string(),
1106 planned,
1107 observed,
1108 });
1109 };
1110
1111 if !observed.enabled {
1112 finding(
1113 AclFindingKind::Disabled,
1114 "access_control",
1115 Some("enabled: true".into()),
1116 Some("enabled: false".into()),
1117 );
1118 }
1119 if observed.default_permission != plan.default_permission.as_str() {
1120 finding(
1121 AclFindingKind::DefaultPermissionDiffers,
1122 "default_permission",
1123 Some(plan.default_permission.as_str().into()),
1124 Some(observed.default_permission.clone()),
1125 );
1126 }
1127
1128 let observed_rules: BTreeMap<&str, &crate::report::AclRuleDoc> =
1130 observed.rules.iter().map(|r| (r.id.as_str(), r)).collect();
1131 for p in &plan.rules {
1132 let shape = planned_rule_shape(p);
1133 match observed_rules.get(p.id.as_str()) {
1134 None => finding(AclFindingKind::RuleMissing, &p.id, Some(shape), None),
1135 Some(o) => {
1136 let o_shape =
1137 rule_shape(&o.permission, o.flows.as_deref(), &o.messages, &o.key_exprs);
1138 if o_shape != shape {
1139 finding(
1140 AclFindingKind::RuleDiffers,
1141 &p.id,
1142 Some(shape),
1143 Some(o_shape),
1144 );
1145 }
1146 }
1147 }
1148 }
1149 let planned_rule_ids: BTreeSet<&str> = plan.rules.iter().map(|r| r.id.as_str()).collect();
1150 for o in &observed.rules {
1151 if !planned_rule_ids.contains(o.id.as_str()) {
1152 finding(
1153 AclFindingKind::RuleExtra,
1154 &o.id,
1155 None,
1156 Some(rule_shape(
1157 &o.permission,
1158 o.flows.as_deref(),
1159 &o.messages,
1160 &o.key_exprs,
1161 )),
1162 );
1163 }
1164 }
1165
1166 let observed_subjects: BTreeMap<&str, &crate::report::AclSubjectDoc> = observed
1168 .subjects
1169 .iter()
1170 .map(|s| (s.id.as_str(), s))
1171 .collect();
1172 let known_cns: BTreeSet<&str> = plan
1173 .subjects
1174 .iter()
1175 .flat_map(|s| s.cert_common_names.iter().map(String::as_str))
1176 .collect();
1177 for p in &plan.subjects {
1178 let planned = format!("cns {:?} zids {:?}", p.cert_common_names, p.zids);
1179 match observed_subjects.get(p.id.as_str()) {
1180 None => finding(AclFindingKind::SubjectMissing, &p.id, Some(planned), None),
1181 Some(o) => {
1182 let o_cns = o.cert_common_names.clone().unwrap_or_default();
1183 let o_zids = o.zids.clone().unwrap_or_default();
1184 if set(o_cns.iter().map(String::as_str))
1185 != set(p.cert_common_names.iter().map(String::as_str))
1186 || set(o_zids.iter().map(String::as_str))
1187 != set(p.zids.iter().map(String::as_str))
1188 {
1189 finding(
1190 AclFindingKind::SubjectDiffers,
1191 &p.id,
1192 Some(planned),
1193 Some(format!("cns {o_cns:?} zids {o_zids:?}")),
1194 );
1195 }
1196 }
1197 }
1198 }
1199 let planned_subject_ids: BTreeSet<&str> = plan.subjects.iter().map(|s| s.id.as_str()).collect();
1200 for o in &observed.subjects {
1201 if !planned_subject_ids.contains(o.id.as_str()) {
1202 finding(
1203 AclFindingKind::SubjectExtra,
1204 &o.id,
1205 None,
1206 Some(format!(
1207 "cns {:?} zids {:?}",
1208 o.cert_common_names.clone().unwrap_or_default(),
1209 o.zids.clone().unwrap_or_default()
1210 )),
1211 );
1212 }
1213 for (prop, value) in [
1214 ("interfaces", &o.interfaces),
1215 ("usernames", &o.usernames),
1216 ("link_protocols", &o.link_protocols),
1217 ] {
1218 if value.as_ref().is_some_and(|v| !v.is_empty()) {
1219 finding(
1220 AclFindingKind::SubjectUnplannedProperty,
1221 &o.id,
1222 None,
1223 Some(format!("{prop}: {:?}", value.clone().unwrap_or_default())),
1224 );
1225 }
1226 }
1227 for cn in o.cert_common_names.iter().flatten() {
1228 if !known_cns.contains(cn.as_str()) {
1229 finding(
1230 AclFindingKind::UnknownCn,
1231 cn,
1232 None,
1233 Some(format!("bound by subject {:?}", o.id)),
1234 );
1235 }
1236 }
1237 }
1238
1239 let policy_key = |rules: &[String], subjects: &[String]| {
1242 format!(
1243 "rules [{}] subjects [{}]",
1244 set(rules.iter().map(String::as_str))
1245 .into_iter()
1246 .collect::<Vec<_>>()
1247 .join(", "),
1248 set(subjects.iter().map(String::as_str))
1249 .into_iter()
1250 .collect::<Vec<_>>()
1251 .join(", ")
1252 )
1253 };
1254 let observed_policies: BTreeSet<String> = observed
1255 .policies
1256 .iter()
1257 .map(|p| policy_key(&p.rules, &p.subjects))
1258 .collect();
1259 let planned_policies: BTreeSet<String> = plan
1260 .policies
1261 .iter()
1262 .map(|p| policy_key(&p.rules, &p.subjects))
1263 .collect();
1264 for p in &plan.policies {
1265 let key = policy_key(&p.rules, &p.subjects);
1266 if !observed_policies.contains(&key) {
1267 finding(AclFindingKind::PolicyMissing, &p.id, Some(key), None);
1268 }
1269 }
1270 for (i, o) in observed.policies.iter().enumerate() {
1271 let key = policy_key(&o.rules, &o.subjects);
1272 if !planned_policies.contains(&key) {
1273 let id = o.id.clone().unwrap_or_else(|| format!("policy #{}", i + 1));
1274 finding(AclFindingKind::PolicyExtra, &id, None, Some(key));
1275 }
1276 }
1277
1278 let judgement = if findings.is_empty() {
1279 Judgement::NotEstablished {
1280 reason: format!(
1281 "{against} carries the plan whole: {} rule(s), {} subject(s), {} polic(y/ies), \
1282 enabled, default deny",
1283 plan.rules.len(),
1284 plan.subjects.len(),
1285 plan.policies.len()
1286 ),
1287 }
1288 } else {
1289 Judgement::Established
1290 };
1291 AclCheck {
1292 base: plan.base.clone(),
1293 against: against.to_string(),
1294 planned_rules: plan.rules.len(),
1295 observed_rules: observed.rules.len(),
1296 planned_subjects: plan.subjects.len(),
1297 observed_subjects: observed.subjects.len(),
1298 findings,
1299 interest_probe: Judgement::NotAsked,
1303 judgement,
1304 }
1305}
1306
1307pub fn explain_acl(
1316 plan: &AclPlan,
1317 principal: &str,
1318 key: &str,
1319 message: AclMessage,
1320) -> Result<AclExplain> {
1321 let subject = plan
1322 .subjects
1323 .iter()
1324 .find(|s| {
1325 s.id == principal
1326 || s.cert_common_names.iter().any(|c| c == principal)
1327 || s.zids.iter().any(|z| z == principal)
1328 })
1329 .ok_or_else(|| {
1330 let refused = plan.refusals.iter().any(|r| r.principal == principal);
1331 Error::unaskable(
1332 "explain",
1333 if refused {
1334 format!("principal {principal:?} was refused by the plan — see its refusal")
1335 } else {
1336 format!(
1337 "principal {principal:?} is not enrolled; the plan knows {:?}",
1338 plan.subjects
1339 .iter()
1340 .map(|s| s.id.as_str())
1341 .collect::<Vec<_>>()
1342 )
1343 },
1344 )
1345 })?;
1346 let k = keyexpr::new(key).map_err(|e| {
1347 Error::unaskable(
1348 "explain",
1349 format!("{key:?} is not a valid key expression (RFC 03 §2): {e}"),
1350 )
1351 })?;
1352
1353 let rule_ids: BTreeSet<&str> = plan
1354 .policies
1355 .iter()
1356 .filter(|p| p.subjects.contains(&subject.id))
1357 .flat_map(|p| p.rules.iter().map(String::as_str))
1358 .collect();
1359 let rules: Vec<&AclRule> = plan
1360 .rules
1361 .iter()
1362 .filter(|r| rule_ids.contains(r.id.as_str()))
1363 .collect();
1364
1365 let direction = |flow: AclFlow| -> AclDirection {
1366 let applicable = rules.iter().filter(|r| {
1367 r.messages.contains(&message) && r.flows.as_ref().is_none_or(|f| f.contains(&flow))
1368 });
1369 let mut via = Vec::new();
1370 let mut near: Vec<String> = Vec::new();
1371 for r in applicable {
1372 let including = r
1373 .key_exprs
1374 .iter()
1375 .find(|e| keyexpr::new(e.as_str()).is_ok_and(|ke| ke.includes(k)));
1376 match including {
1377 Some(e) => via.push(AclGrant {
1378 rule: r.id.clone(),
1379 permission: r.permission,
1380 key_expr: e.clone(),
1381 purpose: r.purpose.clone(),
1382 }),
1383 None => {
1384 if let Some(e) = r
1385 .key_exprs
1386 .iter()
1387 .find(|e| keyexpr::new(e.as_str()).is_ok_and(|ke| ke.intersects(k)))
1388 {
1389 near.push(format!("{} ({e})", r.id));
1390 }
1391 }
1392 }
1393 }
1394 via.sort_by_key(|g| g.permission == AclPermission::Allow);
1396 let denies: Vec<&str> = via
1397 .iter()
1398 .filter(|g| g.permission == AclPermission::Deny)
1399 .map(|g| g.rule.as_str())
1400 .collect();
1401 let allows: Vec<&str> = via
1402 .iter()
1403 .filter(|g| g.permission == AclPermission::Allow)
1404 .map(|g| g.rule.as_str())
1405 .collect();
1406 let (decision, mut reason) = if !denies.is_empty() {
1407 (
1408 AclDecision::Denied,
1409 format!(
1410 "{} denies {} on {} — deny wins{}",
1411 denies.join(", "),
1412 message.as_str(),
1413 flow.as_str(),
1414 if allows.is_empty() {
1415 String::new()
1416 } else {
1417 format!(" over {}", allows.join(", "))
1418 }
1419 ),
1420 )
1421 } else if !allows.is_empty() {
1422 (
1423 AclDecision::Allowed,
1424 format!(
1425 "{} includes it for {} on {}",
1426 allows.join(", "),
1427 message.as_str(),
1428 flow.as_str()
1429 ),
1430 )
1431 } else {
1432 (
1433 AclDecision::DeniedByDefault,
1434 format!(
1435 "no rule of {}'s policies includes {key} for {} on {}: default_permission \
1436 deny",
1437 subject.id,
1438 message.as_str(),
1439 flow.as_str()
1440 ),
1441 )
1442 };
1443 if decision == AclDecision::DeniedByDefault && !near.is_empty() {
1444 let verbatim = key.split('/').any(|c| c.starts_with('@'));
1445 reason.push_str(&format!(
1446 " — {} intersect{} it but ACL matching is inclusion (RFC 09 §3 fact 1){}",
1447 near.join(", "),
1448 if near.len() == 1 { "s" } else { "" },
1449 if verbatim {
1450 ", and `**` never crosses a verbatim `@` chunk (RFC 03 §4 D2): name the \
1451 plane in its own rule"
1452 } else {
1453 ""
1454 }
1455 ));
1456 }
1457 AclDirection {
1458 decision,
1459 via,
1460 reason,
1461 }
1462 };
1463
1464 Ok(AclExplain {
1465 principal: subject.id.clone(),
1466 key: key.to_string(),
1467 message,
1468 base: plan.base.clone(),
1469 ingress: direction(AclFlow::Ingress),
1470 egress: direction(AclFlow::Egress),
1471 })
1472}
1473
1474#[cfg(test)]
1475mod tests {
1476 use super::*;
1477 use crate::report::{FleetSpec, PrincipalSpec};
1478
1479 fn principal(cn: &str, role: Role) -> PrincipalSpec {
1480 PrincipalSpec {
1481 cn: Some(cn.into()),
1482 role,
1483 ..Default::default()
1484 }
1485 }
1486
1487 fn fleet() -> Enrollment {
1489 Enrollment {
1490 base: Some("zensight".into()),
1491 fleet: FleetSpec {
1492 catalog_adv: true,
1493 salt: Some("example-salt-v1".into()),
1494 },
1495 principal: vec![
1496 PrincipalSpec {
1497 origin: Some("h-3fa9c2d41b7e".into()),
1498 adv: true,
1499 blob_seed: true,
1500 media: true,
1501 ..principal("h-3fa9c2d41b7e", Role::Host)
1502 },
1503 PrincipalSpec {
1505 machine_id: Some("b642b4217b34b1e8d3bd915fc65c4452".into()),
1506 ..principal("edge-02.example", Role::Host)
1507 },
1508 PrincipalSpec {
1509 origin: Some("h-0123456789ab".into()),
1510 adv: true,
1511 ..principal("h-0123456789ab", Role::Host)
1512 },
1513 principal("zensight-catalog", Role::Catalog),
1514 PrincipalSpec {
1515 adv: true,
1516 ..principal("zensight-console", Role::Console)
1517 },
1518 principal("zensight-watch", Role::Watch),
1519 ],
1520 }
1521 }
1522
1523 fn plan() -> AclPlan {
1524 plan_acl(&fleet(), "zensight", None, AclOptions::default())
1525 }
1526
1527 fn rule_of<'p>(plan: &'p AclPlan, id: &str) -> &'p AclRule {
1528 plan.rules
1529 .iter()
1530 .find(|r| r.id == id)
1531 .unwrap_or_else(|| panic!("rule {id}"))
1532 }
1533
1534 fn policy_of<'p>(plan: &'p AclPlan, id: &str) -> &'p AclPolicy {
1535 plan.policies
1536 .iter()
1537 .find(|p| p.id == id)
1538 .unwrap_or_else(|| panic!("policy {id}"))
1539 }
1540
1541 #[test]
1544 fn the_three_lists_are_present_and_nothing_was_refused() {
1545 let p = plan();
1546 assert!(p.refusals.is_empty(), "{:?}", p.refusals);
1547 assert_eq!(p.subjects.len(), 6);
1548 assert_eq!(p.policies.len(), 6);
1549 assert!(!p.rules.is_empty());
1550 assert_eq!(p.default_permission, AclPermission::Deny);
1551 for pol in &p.policies {
1553 for r in &pol.rules {
1554 assert!(p.rules.iter().any(|x| &x.id == r), "{r} in {}", pol.id);
1555 }
1556 for s in &pol.subjects {
1557 assert!(p.subjects.iter().any(|x| &x.id == s), "{s} in {}", pol.id);
1558 }
1559 }
1560 let ids: BTreeSet<&str> = p.rules.iter().map(|r| r.id.as_str()).collect();
1562 assert_eq!(ids.len(), p.rules.len());
1563 }
1564
1565 #[test]
1568 fn a_host_gets_one_rule_per_plane_and_the_data_rule_reaches_no_plane() {
1569 let p = plan();
1570 let pol = policy_of(&p, "h-3fa9c2d41b7e");
1571 assert_eq!(
1572 pol.rules,
1573 [
1574 "host-data-h-3fa9c2d41b7e",
1575 "host-serve-h-3fa9c2d41b7e",
1576 "host-media-h-3fa9c2d41b7e",
1577 "host-blob-seed-h-3fa9c2d41b7e",
1578 "host-adv-h-3fa9c2d41b7e",
1579 INTEREST_PROP,
1580 ]
1581 );
1582 let data = rule_of(&p, "host-data-h-3fa9c2d41b7e");
1583 assert_eq!(data.key_exprs, ["zensight/v1/h-3fa9c2d41b7e/**"]);
1584 assert_eq!(data.flows.as_deref(), Some(&[AclFlow::Ingress][..]));
1585 let data_ke = keyexpr::new(data.key_exprs[0].as_str()).unwrap();
1586 for plane in [
1587 "zensight/v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect",
1588 "zensight/v1/h-3fa9c2d41b7e/@media/parallax/cam0/video/h264/high",
1589 "zensight/v1/h-3fa9c2d41b7e/@blob/store/sha256/abc",
1590 "zensight/v1/h-3fa9c2d41b7e/state/x/@adv/pub/zid/1",
1591 ] {
1592 assert!(
1593 !data_ke.includes(keyexpr::new(plane).unwrap()),
1594 "host-data must not reach {plane}"
1595 );
1596 }
1597 let serve = rule_of(&p, "host-serve-h-3fa9c2d41b7e");
1598 assert_eq!(
1599 serve.flows, None,
1600 "both directions: query egress, reply ingress"
1601 );
1602 assert_eq!(
1603 serve.key_exprs,
1604 [
1605 "zensight/v1/h-3fa9c2d41b7e/@rpc/**",
1606 "zensight/v1/h-3fa9c2d41b7e/@blob/**"
1607 ]
1608 );
1609 assert_eq!(
1611 policy_of(&p, "edge-02.example").rules,
1612 [
1613 "host-data-h-20609002f7b6",
1614 "host-serve-h-20609002f7b6",
1615 INTEREST_PROP
1616 ]
1617 );
1618 }
1619
1620 #[test]
1623 fn a_machine_id_computes_the_origin_and_a_disagreement_is_refused() {
1624 let p = plan();
1625 let s = p
1626 .subjects
1627 .iter()
1628 .find(|s| s.id == "edge-02.example")
1629 .unwrap();
1630 assert_eq!(s.cert_common_names, ["edge-02.example"]);
1631 assert!(rule_of(&p, "host-data-h-20609002f7b6").key_exprs[0].contains("h-20609002f7b6"));
1632
1633 let mut e = fleet();
1634 e.principal[1].origin = Some("h-000000000000".into());
1635 let p = plan_acl(&e, "zensight", None, AclOptions::default());
1636 assert_eq!(p.refusals.len(), 1);
1637 assert_eq!(p.refusals[0].principal, "edge-02.example");
1638 assert!(
1639 p.refusals[0].reason.contains("h-20609002f7b6"),
1640 "{}",
1641 p.refusals[0].reason
1642 );
1643 assert_eq!(p.subjects.len(), 5, "the refused principal is left out");
1644
1645 let mut e = fleet();
1647 e.fleet.salt = None;
1648 let p = plan_acl(&e, "zensight", None, AclOptions::default());
1649 assert!(p.refusals[0].reason.contains("salt"));
1650 }
1651
1652 #[test]
1655 fn the_catalog_is_its_own_rule_and_a_star_never_reaches_it() {
1656 let p = plan();
1657 let own = rule_of(&p, "catalog-own-catalog");
1658 assert_eq!(
1659 own.key_exprs,
1660 [
1661 "zensight/v1/@catalog/**",
1662 "zensight/v1/@catalog/@rpc/**",
1663 "zensight/v1/@catalog/**/@adv/**"
1664 ]
1665 );
1666 let star = keyexpr::new("zensight/v1/*/state/**").unwrap();
1667 assert!(!star.includes(keyexpr::new("zensight/v1/@catalog/state/pdns/x").unwrap()));
1668 let sub = rule_of(&p, "ops-sub");
1670 assert!(
1671 sub.key_exprs
1672 .contains(&"zensight/v1/@catalog/**".to_string())
1673 );
1674 assert!(
1675 sub.key_exprs
1676 .contains(&"zensight/v1/@catalog/@rpc/**".to_string())
1677 );
1678 assert!(
1679 sub.key_exprs
1680 .contains(&"zensight/v1/@catalog/**/@adv/**".to_string())
1681 );
1682 assert!(
1683 !keyexpr::new("zensight/v1/**/@adv/**")
1684 .unwrap()
1685 .includes(keyexpr::new("zensight/v1/@catalog/state/x/@adv/pub/z/1").unwrap()),
1686 "the fifth fact's corollary: `**` cannot cross @catalog"
1687 );
1688 }
1689
1690 #[test]
1692 fn every_consumer_policy_allows_its_declarations() {
1693 let p = plan();
1694 for (pid, rid) in [
1695 ("zensight-catalog", "catalog-intake-declare-catalog"),
1696 ("zensight-console", "ops-sub"),
1697 ("zensight-watch", "watch-sub"),
1698 ] {
1699 assert!(policy_of(&p, pid).rules.iter().any(|r| r == rid), "{pid}");
1700 let r = rule_of(&p, rid);
1701 assert_eq!(r.flows.as_deref(), Some(&[AclFlow::Ingress][..]));
1702 for m in [
1703 AclMessage::DeclareSubscriber,
1704 AclMessage::DeclareLivelinessSubscriber,
1705 AclMessage::Query,
1706 ] {
1707 assert!(r.messages.contains(&m), "{rid} lacks {}", m.as_str());
1708 }
1709 }
1710 let recv = rule_of(&p, "catalog-intake-recv-catalog");
1713 assert_eq!(recv.flows.as_deref(), Some(&[AclFlow::Egress][..]));
1714 assert!(recv.messages.contains(&AclMessage::Delete));
1715 let watch = rule_of(&p, "watch-sub");
1717 assert!(
1718 !watch
1719 .key_exprs
1720 .iter()
1721 .any(|e| e.contains("@media") || e.contains("@blob"))
1722 );
1723 let console = rule_of(&p, "ops-sub");
1724 assert!(console.key_exprs.iter().any(|e| e.contains("@media")));
1725 assert!(console.key_exprs.iter().any(|e| e.contains("@blob")));
1726 }
1727
1728 #[test]
1732 fn interest_prop_rides_every_host_policy_egress_only() {
1733 let p = plan();
1734 for pid in ["h-3fa9c2d41b7e", "edge-02.example", "h-0123456789ab"] {
1735 assert!(
1736 policy_of(&p, pid).rules.iter().any(|r| r == INTEREST_PROP),
1737 "{pid}"
1738 );
1739 }
1740 assert!(
1741 policy_of(&p, "zensight-catalog")
1742 .rules
1743 .iter()
1744 .any(|r| r == INTEREST_PROP)
1745 );
1746 let r = rule_of(&p, INTEREST_PROP);
1747 assert_eq!(r.flows.as_deref(), Some(&[AclFlow::Egress][..]));
1748 assert!(!r.messages.contains(&AclMessage::Put));
1749 assert!(!r.messages.contains(&AclMessage::Reply));
1750 assert!(r.messages.contains(&AclMessage::DeclareSubscriber));
1751 assert!(r.messages.contains(&AclMessage::Query));
1752 let own = keyexpr::new("zensight/v1/h-3fa9c2d41b7e/@rpc/**").unwrap();
1755 let fanout = keyexpr::new("zensight/v1/*/@rpc/netlink/sockets").unwrap();
1756 assert!(!own.includes(fanout));
1757 assert!(
1758 r.key_exprs
1759 .iter()
1760 .any(|e| keyexpr::new(e.as_str()).unwrap().includes(fanout))
1761 );
1762 let firehose = keyexpr::new("zensight/v1/**").unwrap();
1763 assert!(
1764 r.key_exprs
1765 .iter()
1766 .any(|e| keyexpr::new(e.as_str()).unwrap().includes(firehose))
1767 );
1768 }
1769
1770 #[test]
1773 fn the_write_set_is_narrowed_by_the_registry_and_unnarrowed_without() {
1774 let p = plan();
1775 let deny = rule_of(&p, NO_REMOTE_ACTIONS);
1776 assert_eq!(deny.permission, AclPermission::Deny);
1777 assert_eq!(deny.messages, [AclMessage::Query]);
1778 assert_eq!(deny.key_exprs, ["zensight/v1/*/@rpc/*/**/set"]);
1779 assert!(
1780 p.warnings
1781 .iter()
1782 .any(|w| w.kind == AclWarningKind::WriteSetNotNarrowed)
1783 );
1784 assert!(p.registry.is_not_asked());
1785 assert!(
1786 policy_of(&p, "zensight-console")
1787 .rules
1788 .iter()
1789 .any(|r| r == NO_REMOTE_ACTIONS)
1790 );
1791 assert!(
1792 policy_of(&p, "zensight-watch")
1793 .rules
1794 .iter()
1795 .any(|r| r == NO_REMOTE_ACTIONS)
1796 );
1797
1798 let dir =
1799 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixture-tests/registry");
1800 let slices = SliceSet::from_dirs(&[dir]).unwrap();
1801 let p = plan_acl(&fleet(), "zensight", Some(&slices), AclOptions::default());
1802 let facts = p.registry.as_option().unwrap();
1803 assert!(
1804 facts
1805 .write_procedures
1806 .contains(&"systemd/action/set".to_string())
1807 );
1808 assert!(facts.media_producers.contains(&"parallax".to_string()));
1809 assert!(facts.blob_producers.contains(&"logs".to_string()));
1810 let deny = rule_of(&p, NO_REMOTE_ACTIONS);
1811 assert!(
1812 deny.key_exprs
1813 .contains(&"zensight/v1/*/@rpc/systemd/action/set".to_string())
1814 );
1815 assert!(
1816 deny.key_exprs
1817 .contains(&"zensight/v1/@catalog/@rpc/catalog/link".to_string())
1818 );
1819 assert!(!deny.key_exprs.iter().any(|e| e.ends_with("/**/set")));
1820 assert!(
1821 !p.warnings
1822 .iter()
1823 .any(|w| w.kind == AclWarningKind::WriteSetNotNarrowed)
1824 );
1825 let mut e = fleet();
1827 e.principal[4].remote_actions = true;
1828 let p = plan_acl(&e, "zensight", Some(&slices), AclOptions::default());
1829 assert!(
1830 !policy_of(&p, "zensight-console")
1831 .rules
1832 .iter()
1833 .any(|r| r == NO_REMOTE_ACTIONS)
1834 );
1835 assert!(
1836 policy_of(&p, "zensight-watch")
1837 .rules
1838 .iter()
1839 .any(|r| r == NO_REMOTE_ACTIONS)
1840 );
1841 }
1842
1843 #[test]
1846 fn a_plane_the_registry_does_not_declare_is_omitted_with_a_warning() {
1847 let dir =
1848 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixture-tests/registry");
1849 let slices = SliceSet::from_dirs(&[dir]).unwrap();
1850 let kept: Vec<_> = slices
1852 .slices()
1853 .iter()
1854 .filter(|s| s.media.is_empty())
1855 .cloned()
1856 .collect();
1857 let narrowed = SliceSet::from_slices(kept);
1858 let p = plan_acl(&fleet(), "zensight", Some(&narrowed), AclOptions::default());
1859 assert!(p.rules.iter().all(|r| !r.id.starts_with("host-media-")));
1860 let w = p
1861 .warnings
1862 .iter()
1863 .find(|w| w.kind == AclWarningKind::PlaneNotDeclared)
1864 .expect("a plane warning");
1865 assert_eq!(w.principal.as_deref(), Some("h-3fa9c2d41b7e"));
1866 assert!(
1867 !rule_of(&p, "ops-sub")
1868 .key_exprs
1869 .iter()
1870 .any(|e| e.contains("@media"))
1871 );
1872 }
1873
1874 #[test]
1875 fn refusals_name_their_reason() {
1876 let mut e = fleet();
1877 e.principal.push(principal("zensight-console", Role::Watch));
1879 e.principal.push(principal("bare-host", Role::Host));
1881 e.principal.push(PrincipalSpec {
1883 zid: Some("38a4829bce9166ee".into()),
1884 ..PrincipalSpec {
1885 cn: None,
1886 role: Role::Watch,
1887 ..Default::default()
1888 }
1889 });
1890 e.principal.push(PrincipalSpec {
1892 remote_actions: true,
1893 ..principal("acting-watch", Role::Watch)
1894 });
1895 let p = plan_acl(&e, "zensight", None, AclOptions::default());
1896 let reasons: Vec<(&str, &str)> = p
1897 .refusals
1898 .iter()
1899 .map(|r| (r.principal.as_str(), r.reason.as_str()))
1900 .collect();
1901 assert_eq!(reasons.len(), 4, "{reasons:?}");
1902 assert!(reasons[0].1.contains("already enrolled"));
1903 assert!(reasons[1].1.contains("`origin` or `machine_id`"));
1904 assert_eq!(reasons[2].0, "38a4829bce9166ee");
1905 assert!(reasons[2].1.contains("--allow-zid-subjects"));
1906 assert!(reasons[3].1.contains("read-only"));
1907 assert_eq!(p.subjects.len(), 6);
1908
1909 let p = plan_acl(
1911 &e,
1912 "zensight",
1913 None,
1914 AclOptions {
1915 allow_zid_subjects: true,
1916 },
1917 );
1918 assert_eq!(p.refusals.len(), 3);
1919 let s = p
1920 .subjects
1921 .iter()
1922 .find(|s| s.id == "38a4829bce9166ee")
1923 .unwrap();
1924 assert_eq!(s.zids, ["38a4829bce9166ee"]);
1925 assert!(s.cert_common_names.is_empty());
1926 assert!(
1927 p.warnings
1928 .iter()
1929 .any(|w| w.kind == AclWarningKind::ZidSubject)
1930 );
1931 }
1932
1933 #[test]
1934 fn the_json5_names_zenohs_fields_and_every_matrix_row() {
1935 let text = to_json5(&plan());
1936 assert!(text.starts_with("// zenohd access_control block"));
1937 assert!(text.contains("access_control: {"));
1938 assert!(text.contains(" enabled: true,"));
1939 assert!(text.contains("default_permission: \"deny\""));
1940 for field in ["rules: [", "subjects: [", "policies: ["] {
1941 assert!(text.contains(field), "{field}");
1942 }
1943 assert!(text.contains(
1944 "{ id: \"host-data-h-3fa9c2d41b7e\", permission: \"allow\", flows: [\"ingress\"],"
1945 ));
1946 assert!(text.contains("messages: [\"put\", \"delete\", \"liveliness_token\"],"));
1947 assert!(text.contains("{ id: \"host-serve-h-3fa9c2d41b7e\", permission: \"allow\",\n"));
1949 assert!(text.contains("// interest-prop: the fifth fact"));
1950 assert!(text.contains("cert_common_names: [\"h-3fa9c2d41b7e\"] }, // host"));
1951 assert!(text.contains("// ! write_set_not_narrowed"));
1952 assert!(!text.contains("\\u"));
1957 }
1958
1959 #[test]
1960 fn explain_answers_per_direction_with_the_rules_that_decided() {
1961 let p = plan();
1962 let x = explain_acl(
1964 &p,
1965 "h-3fa9c2d41b7e",
1966 "zensight/v1/h-3fa9c2d41b7e/state/sysinfo/health",
1967 AclMessage::Put,
1968 )
1969 .unwrap();
1970 assert_eq!(x.ingress.decision, AclDecision::Allowed);
1971 assert_eq!(x.ingress.via[0].rule, "host-data-h-3fa9c2d41b7e");
1972 assert_eq!(x.egress.decision, AclDecision::DeniedByDefault);
1973
1974 let x = explain_acl(
1976 &p,
1977 "h-3fa9c2d41b7e",
1978 "zensight/v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect",
1979 AclMessage::Reply,
1980 )
1981 .unwrap();
1982 assert_eq!(x.ingress.decision, AclDecision::Allowed);
1983 assert_eq!(x.ingress.via[0].rule, "host-serve-h-3fa9c2d41b7e");
1984
1985 let x = explain_acl(
1988 &p,
1989 "h-3fa9c2d41b7e",
1990 "zensight/v1/h-0123456789ab/state/sysinfo/health",
1991 AclMessage::Put,
1992 )
1993 .unwrap();
1994 assert_eq!(x.ingress.decision, AclDecision::DeniedByDefault);
1995 assert!(x.ingress.via.is_empty());
1996
1997 let x = explain_acl(
1999 &p,
2000 "zensight-console",
2001 "zensight/v1/h-3fa9c2d41b7e/@rpc/systemd/action/set",
2002 AclMessage::Query,
2003 )
2004 .unwrap();
2005 assert_eq!(x.ingress.decision, AclDecision::Denied);
2006 assert_eq!(x.ingress.via[0].rule, NO_REMOTE_ACTIONS);
2007 assert_eq!(x.ingress.via[1].rule, "ops-sub");
2008 assert!(x.ingress.reason.contains("deny wins over ops-sub"));
2009
2010 assert!(explain_acl(&p, "zensight-watch", "zensight/v1/**", AclMessage::Put).is_ok());
2012 let e = explain_acl(&p, "nobody", "zensight/v1/**", AclMessage::Put).unwrap_err();
2013 assert!(e.is_unaskable());
2014 let e = explain_acl(&p, "zensight-watch", "zensight//x", AclMessage::Put).unwrap_err();
2015 assert!(e.is_unaskable());
2016 }
2017
2018 #[test]
2021 fn check_finds_what_differs_and_only_that() {
2022 let p = plan();
2023 let mut doc = AclConfigDoc {
2026 enabled: true,
2027 default_permission: "deny".into(),
2028 rules: p
2029 .rules
2030 .iter()
2031 .map(|r| crate::report::AclRuleDoc {
2032 id: r.id.clone(),
2033 key_exprs: r.key_exprs.clone(),
2034 messages: r.messages.iter().map(|m| m.as_str().to_string()).collect(),
2035 flows: r
2036 .flows
2037 .as_ref()
2038 .map(|f| f.iter().map(|x| x.as_str().to_string()).collect()),
2039 permission: r.permission.as_str().into(),
2040 })
2041 .collect(),
2042 subjects: p
2043 .subjects
2044 .iter()
2045 .map(|s| crate::report::AclSubjectDoc {
2046 id: s.id.clone(),
2047 cert_common_names: Some(s.cert_common_names.clone()),
2048 ..Default::default()
2049 })
2050 .collect(),
2051 policies: p
2052 .policies
2053 .iter()
2054 .map(|pol| crate::report::AclPolicyDoc {
2055 id: None,
2056 rules: pol.rules.clone(),
2057 subjects: pol.subjects.clone(),
2058 })
2059 .collect(),
2060 };
2061 let c = check_acl(&p, &doc, "router.json5");
2062 assert!(c.findings.is_empty(), "{:?}", c.findings);
2063 assert!(matches!(c.judgement, Judgement::NotEstablished { .. }));
2064 assert_eq!(c.interest_probe, Judgement::NotAsked);
2065
2066 doc.enabled = false;
2068 doc.rules.retain(|r| r.id != INTEREST_PROP);
2069 doc.rules[0].flows = None;
2070 doc.subjects.push(crate::report::AclSubjectDoc {
2071 id: "stranger".into(),
2072 cert_common_names: Some(vec!["not-enrolled".into()]),
2073 interfaces: Some(vec!["eth0".into()]),
2074 ..Default::default()
2075 });
2076 doc.policies[0].rules.pop();
2077 let c = check_acl(&p, &doc, "router.json5");
2078 let kinds: Vec<AclFindingKind> = c.findings.iter().map(|f| f.kind).collect();
2079 assert!(kinds.contains(&AclFindingKind::Disabled));
2080 assert!(kinds.contains(&AclFindingKind::RuleMissing));
2081 assert!(kinds.contains(&AclFindingKind::RuleDiffers));
2082 assert!(kinds.contains(&AclFindingKind::SubjectExtra));
2083 assert!(kinds.contains(&AclFindingKind::SubjectUnplannedProperty));
2084 assert!(kinds.contains(&AclFindingKind::UnknownCn));
2085 assert!(kinds.contains(&AclFindingKind::PolicyMissing));
2086 assert!(kinds.contains(&AclFindingKind::PolicyExtra));
2087 assert_eq!(c.judgement, Judgement::Established);
2088 assert_eq!(crate::judgement_exit_code(&c.judgement), 1);
2089 }
2090
2091 #[test]
2092 fn a_procedure_path_with_variables_becomes_a_pattern() {
2093 assert_eq!(procedure_pattern("config/{ns}/{if}/set"), "config/*/*/set");
2094 assert_eq!(procedure_pattern("action/set"), "action/set");
2095 }
2096}