Skip to main content

zenkey_fleet/model/
acl.rs

1//! The ACL planner (RFC 09 §3, #392): an enrollment file plus, when given,
2//! the registry in; the router's `access_control` block out — with every
3//! rule naming the matrix row it instantiates and the fact it exists for,
4//! every narrowing shown, and every refusal named.
5//!
6//! RFC 09 §3 has carried a complete recipe since v1.0 and it went undeployed
7//! for a year, because writing it by hand is miserable and unverifiable.
8//! Four facts shape it, and each one is a way a hand-written block fails
9//! **silently and partially**:
10//!
11//! 1. Matching is keyexpr *inclusion*, and `**` never crosses a verbatim
12//!    chunk there either — a host's `…/h-xxx/**` rule does not cover its
13//!    `@rpc` replies, `@media` frames or `@blob` keys. One rule per plane.
14//! 2. `*` in the origin position never covers `@catalog`; catalog access is
15//!    its own rule (RFC 03 §4 D4).
16//! 3. The config needs all three lists — `rules`, `subjects`, `policies`;
17//!    rules alone are refused at router startup.
18//! 4. Under `default_permission: "deny"`, *declarations* need allowing: a
19//!    consumer that may not `declare_subscriber` receives nothing, a
20//!    producer that may not `declare_queryable` serves nothing, and a query
21//!    must be allowed *egress* toward the responder as well as ingress from
22//!    the caller.
23//!
24//! And a fifth, from the reference deployment (2026-08-30), RFC 09 §3
25//! fact 5 since v1.33: zenoh evaluates a consumer's declares and queries **on egress
26//! toward the responding face, against that face's subject**. A host whose
27//! grants are all own-origin (`…/h-xxx/**`) includes no wildcard-origin
28//! selector, so a console's `…/v1/**` interest never reaches it, and a
29//! peer-mode publisher with no matching interest publishes to nobody. The
30//! fix is one shared, **egress-only** rule — [`INTEREST_PROP`] — allowing
31//! declares and queries on the fleet's selectors, attached to every
32//! publishing principal. It is security-neutral: puts and replies stay
33//! ingress-checked. The same fact has a corollary: `**` cannot cross
34//! `@catalog` any more than `@adv`, so `@catalog/**/@adv/**` needs spelling
35//! out wherever the catalog runs the advanced tier.
36//!
37//! Pure, like everything in [`crate::model`]: values in hand, no session.
38//! [`plan_acl`] takes an *optional* registry and says what it could not
39//! narrow without one rather than guessing (RFC 13 §3 O4); [`check_acl`]
40//! compares a plan against a block somebody else read off a router's config
41//! file; [`explain_acl`] answers "does this principal hold this grant, via
42//! which rule, in which direction" over the plan alone, with inclusion
43//! computed by `zenoh-keyexpr`. [`to_json5`] is the one rendering of the
44//! plan that is not zenkey's — it is `zenohd`'s, field for field
45//! `zenoh-config-1.10.0/src/lib.rs`.
46
47use 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
60/// The shared egress-only rule every publishing principal carries — the
61/// fifth fact (module doc).
62pub const INTEREST_PROP: &str = "interest-prop";
63/// The shared deny on the write procedures — the console's
64/// `no-remote-actions` row, narrowed by the registry when one was asked.
65pub const NO_REMOTE_ACTIONS: &str = "no-remote-actions";
66/// The convention's write-procedure leaf (RFC 09 §3, v1.4: a write is keyed
67/// `…/set` so a rule can see the actuated resource) — the deny's shape when
68/// no registry narrowed it.
69pub const UNNARROWED_WRITE_LEAF: &str = "v1/*/@rpc/*/**/set";
70
71/// What the caller decided about the enrollment's dangerous corners.
72#[derive(Debug, Clone, Copy, Default)]
73pub struct AclOptions {
74    /// Admit `zid = "…"` subjects. Off by default: a ZID is not backed by
75    /// authentication (zenoh's own config says so).
76    pub allow_zid_subjects: bool,
77}
78
79// ── The matrix rows ───────────────────────────────────────────────────────
80
81const 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
98/// The planes a consumer names, each spelled because `**` reaches none of
99/// them from `v1/**` (fact 1) and `*` never reaches `@catalog` (fact 2).
100struct 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
126/// The `@adv` sidecar expressions, catalog sibling included when the
127/// catalog runs the tier.
128fn 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
159/// A service origin's chunk without its `@`, for rule ids.
160fn id_chunk(origin: &str) -> &str {
161    origin.strip_prefix('@').unwrap_or(origin)
162}
163
164// ── The registry's part ───────────────────────────────────────────────────
165
166fn 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
199/// A procedure path as a key expression: every `{var}` chunk is a `*`.
200fn 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
213/// Every declared write procedure's key expression, under the base.
214fn 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
240// ── The principals ────────────────────────────────────────────────────────
241
242/// How a principal names itself in a refusal.
243fn 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
250/// A host's origin: given, computed from its machine-id, or both in
251/// agreement (RFC 06 §1).
252fn 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    // The salt is an application *constant* by design (RFC 06 §1 "salt
287    // scope"), and `OriginSalt` says so in its type. The enrollment's stands
288    // in for one, and leaking it is what makes a runtime string into that
289    // constant — once per plan, a few bytes, in a process that plans once.
290    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
306/// A service principal's origin: the default for its role, or a verbatim
307/// `@…` chunk it names.
308fn 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/// Rules keyed by id, in first-insertion order; a second identical push is
337/// a no-op (two CNs enrolled for one origin share its rules).
338#[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
363// ── The plan ──────────────────────────────────────────────────────────────
364
365/// Plan the `access_control` block for an enrollment under `base`.
366///
367/// `slices`, when given, **narrows** the plan to what the fleet serves: a
368/// host's `@media` and `@blob` rules exist only where some host producer
369/// declares the plane, and `no-remote-actions` denies exactly the declared
370/// `kind = "write"` procedures. Without one, the planes are what the
371/// enrollment claims and the deny is the convention's `set` leaf — and the
372/// plan says so (RFC 13 §3 O4).
373///
374/// A refused principal is left out of `subjects` and `policies` and named
375/// in `refusals`; the plan is still emitted around it.
376pub 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    // What the fleet serves, for the consumers' plane lists: without a
393    // registry, everything the enrollment's hosts claim.
394    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` is served by any declarer; seeding is one host's
402            // choice on top.
403            blob: !f.blob_producers.is_empty(),
404            catalog_adv,
405        },
406        None => Planes {
407            media: claimed_media,
408            // Without a registry the RFC row stands: every host serves
409            // `@blob` (RFC 07 §2 makes it a plane every host may serve).
410            blob: true,
411            catalog_adv,
412        },
413    };
414
415    // The write set the consumers are denied.
416    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        // The subject: who this is on the wire.
481        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        // The role's rules.
555        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                // `@blob` rides host-serve where the plane is served at all.
582                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
899// ── JSON5 ─────────────────────────────────────────────────────────────────
900
901fn 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
910/// The plan as the `access_control` block `zenohd` reads — with a comment
911/// per rule naming the matrix row and the fact it exists for.
912///
913/// Field names are zenoh 1.10's (`zenoh-config-1.10.0/src/lib.rs`:
914/// `AclConfig`, `AclConfigRule`, `AclMessage`, `InterceptorFlow`,
915/// `AclConfigSubjects`, `AclConfigPolicyEntry`); `flows` is omitted where the
916/// rule applies in both directions, which is what zenoh reads an absent
917/// `flows` as.
918pub 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
1053// ── --check ───────────────────────────────────────────────────────────────
1054
1055fn set<'a>(items: impl IntoIterator<Item = &'a str>) -> BTreeSet<String> {
1056    items.into_iter().map(str::to_string).collect()
1057}
1058
1059/// A rule's comparable form: flows normalised so that absent = both.
1060fn 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
1094/// The plan against the block a router's config file carries.
1095///
1096/// The observed side is what zenoh's own loader parsed, so what is compared
1097/// is what `zenohd` would run; `against` names the file (RFC 13 §3 O5). The
1098/// judged claim is *the block differs from the plan* — a finding is
1099/// `Established`, a block that carries the plan whole is `NotEstablished`.
1100pub 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    // Rules, by id.
1129    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    // Subjects, by id — and every configured CN against the enrollment.
1167    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    // Policies, as sets of (rules, subjects) — ids are optional in zenoh's
1240    // shape, so identity is what a policy binds.
1241    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        // Observable only from a publisher's matching listener (RFC 07 §1);
1300        // a config-file check has no publisher to ask, and a consumer-side
1301        // subscriber cannot see whether its interest was forwarded.
1302        interest_probe: Judgement::NotAsked,
1303        judgement,
1304    }
1305}
1306
1307// ── --explain ─────────────────────────────────────────────────────────────
1308
1309/// Does `principal` hold `message` on `key`, in each direction, and via
1310/// which rules — over the plan alone, inclusion by `zenoh-keyexpr`.
1311///
1312/// `principal` is a subject id, a CN or a zid. A principal the plan does
1313/// not carry (unknown, or refused) is an [`Error::Unaskable`]: there is no
1314/// policy to explain.
1315pub 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        // Deny first, so the reader sees what won.
1395        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    /// Three hosts, a catalog, a console, a watch — the fixture fleet.
1488    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                // RFC 06 §1's test vector: computed, not given.
1504                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    /// Fact 3: three lists, all present; one subject and one policy per
1542    /// enrolled principal.
1543    #[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        // Every policy names rules that exist and a subject that exists.
1552        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        // Rule ids are unique — zenoh refuses a duplicate.
1561        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    /// Fact 1: one rule per plane, and the host's `**` rule reaches none of
1566    /// them.
1567    #[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        // A plain host: data, serve, interest-prop and nothing else.
1610        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    /// RFC 06 §1's test vector: the origin is computed from the machine-id,
1621    /// and a given origin that disagrees is a refusal.
1622    #[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        // No salt: the derivation cannot run.
1646        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    /// Fact 2: catalog access is its own rule, spelled on the verbatim
1653    /// origin, and `*` never reaches it.
1654    #[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        // And the consumers name it explicitly, adv sibling included.
1669        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    /// Fact 4: every consumer policy carries ingress declares.
1691    #[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        // The catalog's intake is split by flow: a flowless rule would let it
1711        // tombstone any host's keys.
1712        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        // A watch never sees @media or @blob; a console does.
1716        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    /// The fifth fact: `interest-prop` on every publishing policy, egress
1729    /// only, declares and queries only — and it includes the selectors
1730    /// consumers actually declare.
1731    #[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        // The console's own selectors are included, `v1/**` and the fan-out
1753        // RPC shape among them — neither is included by any own-origin rule.
1754        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    /// Without a registry the deny is the convention's `set` leaf and the
1771    /// plan says so; with one it is the declared write set.
1772    #[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        // A console allowed to act drops the deny.
1826        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    /// The registry narrows the planes: a claimed plane no producer declares
1844    /// is omitted, and said so.
1845    #[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        // A registry with the media producer removed.
1851        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        // Two principals sharing a CN.
1878        e.principal.push(principal("zensight-console", Role::Watch));
1879        // A host with neither origin nor machine_id.
1880        e.principal.push(principal("bare-host", Role::Host));
1881        // A zid subject without the flag.
1882        e.principal.push(PrincipalSpec {
1883            zid: Some("38a4829bce9166ee".into()),
1884            ..PrincipalSpec {
1885                cn: None,
1886                role: Role::Watch,
1887                ..Default::default()
1888            }
1889        });
1890        // A read-only watch asking to act.
1891        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        // With the flag, the zid subject is admitted and warned about.
1910        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        // A flowless rule has no `flows` key at all.
1948        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        // And it round-trips through a JSON parser once the comments and the
1953        // bare keys are what a JSON5 reader accepts — checked here through
1954        // zenoh's own loader in the zenctl corpus; here, that every quoted
1955        // string is valid JSON.
1956        assert!(!text.contains("\\u"));
1957    }
1958
1959    #[test]
1960    fn explain_answers_per_direction_with_the_rules_that_decided() {
1961        let p = plan();
1962        // A host's own put: allowed on ingress, nothing on egress.
1963        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        // Its @rpc reply: host-data does not reach it; host-serve does.
1975        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        // Another host's origin: denied by default, and the reason names the
1986        // convention.
1987        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        // The console calling a write: denied, deny wins over ops-sub.
1998        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        // A CN resolves to its subject; an unknown principal is unaskable.
2011        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    /// The plan's own JSON5, parsed back as zenoh would, checks clean; a
2019    /// hand-edited block does not.
2020    #[test]
2021    fn check_finds_what_differs_and_only_that() {
2022        let p = plan();
2023        // The observed side built from the plan itself, as a loader would
2024        // hand it back.
2025        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        // Now break it four ways.
2067        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}