Skip to main content

zenkey_fleet/report/
acl.rs

1//! The ACL plan (RFC 09 §3, #392): what an enrollment file asks for, what
2//! the registry narrows it to, and how a router's configured block compares.
3//!
4//! Four documents cross the wire here. [`Enrollment`] comes *in* — the small
5//! TOML an operator writes binding certificate CNs to roles and origins
6//! (RFC 03 §4 D6) — and it is here rather than beside the planner because a
7//! `Deserialize` shape is somebody else's file format, which is the
8//! placement rule's whole test. [`AclPlan`] goes *out* as the plan,
9//! [`AclCheck`] as the verdict of `--check`, [`AclExplain`] as `--explain`'s
10//! answer; [`AclConfigDoc`] is the `access_control` block as zenoh's own
11//! loader parsed it, the observed side of a check.
12//!
13//! The rule/subject/policy vocabulary below is **zenoh 1.10's**, verbatim:
14//! `zenoh-config-1.10.0/src/lib.rs` — `AclConfig` (`enabled`,
15//! `default_permission`, `rules`, `subjects`, `policies`), `AclConfigRule`
16//! (`id`, `key_exprs`, `messages`, `flows`, `permission`), `AclMessage`
17//! (the nine snake_case message kinds), `InterceptorFlow`
18//! (`egress`/`ingress`), `AclConfigSubjects` (`id`, `cert_common_names`,
19//! `zids`, …) and `AclConfigPolicyEntry` (`id`, `rules`, `subjects`). A
20//! rule with `flows` absent applies in both directions. The planner itself
21//! is [`crate::model::acl`]; nothing here computes.
22
23use serde::{Deserialize, Serialize};
24
25use super::asked::Asked;
26use super::judgement::Judgement;
27
28// ── The enrollment file ───────────────────────────────────────────────────
29
30/// The enrollment file `zenctl acl gen --enrollment` reads (#392).
31///
32/// One `[[principal]]` per transport identity, each bound to a role and —
33/// for a host — to the origin it may act as (RFC 03 §4 D6: without this
34/// binding, D6 is a hygiene boundary, not a security one). Everything the
35/// router needs beyond that is derived.
36///
37/// ```toml
38/// base = "zensight"                      # optional; default = --base / context / ""
39///
40/// [fleet]
41/// catalog_adv = true                     # the catalog runs the advanced tier:
42///                                        # spell @catalog/**/@adv/** explicitly
43/// salt = "zensight-host-id-v1"           # the app's RFC 06 §1 origin salt —
44///                                        # needed only where a host gives machine_id
45///
46/// [[principal]]
47/// cn = "h-3fa9c2d41b7e"                  # the mTLS certificate CN
48/// role = "host"                          # host | catalog | console | desired-author | watch
49/// origin = "h-3fa9c2d41b7e"              # or machine_id = "<32 hex>" (+ fleet.salt);
50///                                        # both given must agree, or the principal is refused
51/// adv = true                             # uses the @adv sidecars (RFC 04 §3.3)
52/// blob_seed = true                       # seeds the router @blob store (RFC 07 §2)
53/// media = true                           # publishes @media streams (RFC 07 §1)
54///
55/// [[principal]]
56/// cn = "zensight-catalog"
57/// role = "catalog"                       # origin defaults to @catalog
58///
59/// [[principal]]
60/// cn = "zensight-console"
61/// role = "console"
62/// adv = true
63/// remote_actions = false                 # true drops the no-remote-actions deny
64///
65/// [[principal]]
66/// cn = "zensight-desired"
67/// role = "desired-author"
68/// origin = "@desired"                    # its own service origin (RFC 07 §3)
69///
70/// [[principal]]
71/// cn = "zensight-watch"
72/// role = "watch"                         # read-only: data classes, catalog, RPC reads
73/// ```
74///
75/// A `zid = "…"` in place of `cn` is accepted only under
76/// `--allow-zid-subjects`: zenoh's own config says a ZID "is not backed by
77/// an authentication mechanism … can be useful for prototyping but should
78/// not be used in production" (`zenoh-1.10.0/DEFAULT_CONFIG.json5`).
79#[derive(Debug, Clone, Default, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct Enrollment {
82    /// The deployment base (RFC 03 §1.1). `None` = take the observer's
83    /// resolved `--base`, the empty base being the bus-root deployment.
84    pub base: Option<String>,
85    #[serde(default)]
86    pub fleet: FleetSpec,
87    #[serde(default)]
88    pub principal: Vec<PrincipalSpec>,
89}
90
91/// The `[fleet]` table: what holds for the deployment rather than for one
92/// principal.
93#[derive(Debug, Clone, Default, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct FleetSpec {
96    /// The catalog runs the advanced tier, so its sidecars live under a
97    /// verbatim `@adv` suffix `**` cannot reach past `@catalog` — every rule
98    /// that names `**/@adv/**` gets a `@catalog/**/@adv/**` sibling.
99    #[serde(default)]
100    pub catalog_adv: bool,
101    /// The application's origin salt (RFC 06 §1), for principals that give
102    /// a `machine_id` rather than an `origin`.
103    pub salt: Option<String>,
104}
105
106/// One enrolled transport identity.
107#[derive(Debug, Clone, Default, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct PrincipalSpec {
110    /// The certificate common name — the one subject property that is
111    /// backed by authentication (RFC 03 §4 D6).
112    pub cn: Option<String>,
113    /// A zenoh id, for prototyping only (`--allow-zid-subjects`).
114    pub zid: Option<String>,
115    /// The subject id in the emitted config. Defaults to the CN (or the
116    /// zid).
117    pub id: Option<String>,
118    pub role: Role,
119    /// The origin this principal acts as: `h-…` for a host, `@…` for a
120    /// service. Defaults to `@catalog` for a catalog and `@desired` for a
121    /// desired-author; required (or derived) for a host.
122    pub origin: Option<String>,
123    /// The host's `/etc/machine-id`, from which the origin is *computed*
124    /// with the RFC 06 §1 derivation and `fleet.salt`.
125    pub machine_id: Option<String>,
126    /// The principal uses the `@adv` sidecars (RFC 04 §3.3).
127    #[serde(default)]
128    pub adv: bool,
129    /// The host seeds the router `@blob` content store (RFC 07 §2).
130    #[serde(default)]
131    pub blob_seed: bool,
132    /// The host publishes `@media` streams (RFC 07 §1).
133    #[serde(default)]
134    pub media: bool,
135    /// A console that may invoke write procedures: drops the
136    /// `no-remote-actions` deny. A watch is read-only by definition and
137    /// refuses this.
138    #[serde(default)]
139    pub remote_actions: bool,
140}
141
142/// The roles RFC 09 §3's grant matrix knows.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
144#[serde(rename_all = "kebab-case")]
145pub enum Role {
146    /// A sensor host: publishes its own origin, serves its own `@rpc`.
147    #[default]
148    Host,
149    /// The catalog service: owns `@catalog`, takes in every host's data.
150    Catalog,
151    /// The operator console: reads every plane, acts only through RPC.
152    Console,
153    /// A desired-state author: writes one service origin's `state`
154    /// subtree and nothing else (RFC 07 §3).
155    DesiredAuthor,
156    /// A read-only observer (an explorer, a watchdog): the data classes,
157    /// the catalog, RPC reads — never `@media`, never `@blob`, never a
158    /// write.
159    Watch,
160}
161
162impl Role {
163    pub fn as_str(self) -> &'static str {
164        match self {
165            Role::Host => "host",
166            Role::Catalog => "catalog",
167            Role::Console => "console",
168            Role::DesiredAuthor => "desired-author",
169            Role::Watch => "watch",
170        }
171    }
172}
173
174// ── zenoh 1.10's vocabulary ───────────────────────────────────────────────
175
176/// `AclMessage` as zenoh 1.10 spells it (`zenoh-config-1.10.0/src/lib.rs`).
177#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum AclMessage {
180    Put,
181    Delete,
182    DeclareSubscriber,
183    Query,
184    DeclareQueryable,
185    Reply,
186    LivelinessToken,
187    DeclareLivelinessSubscriber,
188    LivelinessQuery,
189}
190
191impl AclMessage {
192    /// Every kind, in zenoh's declaration order.
193    pub const ALL: [AclMessage; 9] = [
194        AclMessage::Put,
195        AclMessage::Delete,
196        AclMessage::DeclareSubscriber,
197        AclMessage::Query,
198        AclMessage::DeclareQueryable,
199        AclMessage::Reply,
200        AclMessage::LivelinessToken,
201        AclMessage::DeclareLivelinessSubscriber,
202        AclMessage::LivelinessQuery,
203    ];
204
205    pub fn as_str(self) -> &'static str {
206        match self {
207            AclMessage::Put => "put",
208            AclMessage::Delete => "delete",
209            AclMessage::DeclareSubscriber => "declare_subscriber",
210            AclMessage::Query => "query",
211            AclMessage::DeclareQueryable => "declare_queryable",
212            AclMessage::Reply => "reply",
213            AclMessage::LivelinessToken => "liveliness_token",
214            AclMessage::DeclareLivelinessSubscriber => "declare_liveliness_subscriber",
215            AclMessage::LivelinessQuery => "liveliness_query",
216        }
217    }
218
219    /// The snake_case token back to the kind — what `--explain` reads.
220    pub fn parse(token: &str) -> Option<AclMessage> {
221        AclMessage::ALL.into_iter().find(|m| m.as_str() == token)
222    }
223}
224
225/// `InterceptorFlow` as zenoh 1.10 spells it.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum AclFlow {
229    Egress,
230    Ingress,
231}
232
233impl AclFlow {
234    pub fn as_str(self) -> &'static str {
235        match self {
236            AclFlow::Egress => "egress",
237            AclFlow::Ingress => "ingress",
238        }
239    }
240}
241
242/// `Permission` as zenoh 1.10 spells it.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "snake_case")]
245pub enum AclPermission {
246    Allow,
247    Deny,
248}
249
250impl AclPermission {
251    pub fn as_str(self) -> &'static str {
252        match self {
253            AclPermission::Allow => "allow",
254            AclPermission::Deny => "deny",
255        }
256    }
257}
258
259// ── The plan ──────────────────────────────────────────────────────────────
260
261/// `zenctl acl gen`: the `access_control` block, with every rule carrying
262/// the matrix row it instantiates and the fact it exists for.
263#[derive(Debug, Clone, Serialize)]
264pub struct AclPlan {
265    /// The base every key expression below was composed under.
266    pub base: String,
267    /// Always `deny` — the recipe has no allow-by-default form (RFC 09 §3
268    /// fact 4).
269    pub default_permission: AclPermission,
270    /// What the registry said, when one was asked. **Absent** when none
271    /// was: the planes are then what the enrollment claims and the write set
272    /// is the convention's `set` leaf, unnarrowed (RFC 13 §3 O4).
273    #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
274    pub registry: Asked<AclRegistryFacts>,
275    pub rules: Vec<AclRule>,
276    pub subjects: Vec<AclSubject>,
277    pub policies: Vec<AclPolicy>,
278    #[serde(skip_serializing_if = "Vec::is_empty")]
279    pub warnings: Vec<AclWarning>,
280    /// Principals the plan left out, and why. A refused principal is
281    /// **omitted** from `subjects` and `policies` and named here — the plan
282    /// is still emitted around it, and the verb exits 1 for it.
283    #[serde(skip_serializing_if = "Vec::is_empty")]
284    pub refusals: Vec<AclRefusal>,
285}
286
287/// The registry, as the plan read it.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
289pub struct AclRegistryFacts {
290    pub slices: usize,
291    /// Host producers declaring `[[media]]`.
292    pub media_producers: Vec<String>,
293    /// Host producers declaring `[[blob]]`.
294    pub blob_producers: Vec<String>,
295    /// Every `kind = "write"` procedure, as `producer/path`.
296    pub write_procedures: Vec<String>,
297}
298
299/// One rule, as `AclConfigRule` will carry it.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
301pub struct AclRule {
302    pub id: String,
303    pub permission: AclPermission,
304    /// `None` = both directions (zenoh: `flows` absent).
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub flows: Option<Vec<AclFlow>>,
307    pub messages: Vec<AclMessage>,
308    pub key_exprs: Vec<String>,
309    /// The RFC 09 §3 matrix row this instantiates.
310    pub purpose: String,
311    /// The fact it exists for.
312    pub cite: String,
313}
314
315/// One subject, as `AclConfigSubjects` will carry it.
316#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
317pub struct AclSubject {
318    pub id: String,
319    pub role: Role,
320    #[serde(skip_serializing_if = "Vec::is_empty")]
321    pub cert_common_names: Vec<String>,
322    /// Prototyping only (`--allow-zid-subjects`).
323    #[serde(skip_serializing_if = "Vec::is_empty")]
324    pub zids: Vec<String>,
325}
326
327/// One policy, as `AclConfigPolicyEntry` will carry it.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
329pub struct AclPolicy {
330    pub id: String,
331    pub rules: Vec<String>,
332    pub subjects: Vec<String>,
333}
334
335/// One thing the plan wants said beside a principal or the fleet.
336#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
337pub struct AclWarning {
338    pub kind: AclWarningKind,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub principal: Option<String>,
341    pub text: String,
342    pub cite: String,
343}
344
345/// The closed vocabulary of plan warnings.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
347#[serde(rename_all = "snake_case")]
348pub enum AclWarningKind {
349    /// No registry was asked, so `no-remote-actions` denies the
350    /// convention's `set` leaf rather than the declared write set.
351    WriteSetNotNarrowed,
352    /// The registry declares no write procedure at all; the deny is
353    /// omitted because zenoh refuses an empty `key_exprs`.
354    NoWriteProcedures,
355    /// The enrollment claims a plane no host producer in the registry
356    /// declares; the plane's rule is omitted.
357    PlaneNotDeclared,
358    /// A `zids` subject, admitted under `--allow-zid-subjects`.
359    ZidSubject,
360    /// A role the fleet has none of — a console-less or catalog-less fleet
361    /// is legal, but rarely what was meant.
362    RoleAbsent,
363}
364
365impl AclWarningKind {
366    pub fn as_str(self) -> &'static str {
367        match self {
368            AclWarningKind::WriteSetNotNarrowed => "write_set_not_narrowed",
369            AclWarningKind::NoWriteProcedures => "no_write_procedures",
370            AclWarningKind::PlaneNotDeclared => "plane_not_declared",
371            AclWarningKind::ZidSubject => "zid_subject",
372            AclWarningKind::RoleAbsent => "role_absent",
373        }
374    }
375}
376
377/// One principal the plan refused to enrol.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
379pub struct AclRefusal {
380    /// The principal as the file named it: its id, CN or zid, or its index.
381    pub principal: String,
382    pub reason: String,
383    pub cite: String,
384}
385
386// ── The observed block ────────────────────────────────────────────────────
387
388/// The `access_control` block as zenoh's own loader parsed it — the observed
389/// side of `--check --against <router.json5>`.
390///
391/// Field for field `AclConfig` (`zenoh-config-1.10.0/src/lib.rs`), so what
392/// is compared is what `zenohd` would run. Read from the router's config
393/// **file**, because zenoh 1.10's admin space does not serve it: the
394/// adminspace registers GET handlers for the root document, `metrics`,
395/// `linkstate`, `subscriber`, `publisher`, `queryable`, `querier`, `token`,
396/// `route/successor` and `plugins` — `config/**` is a *subscriber* for
397/// runtime edits, never a queryable, and the root document carries no
398/// `access_control` (`zenoh-1.10.0/src/net/runtime/adminspace.rs`,
399/// `add_handler!` and `local_data`).
400#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
401pub struct AclConfigDoc {
402    #[serde(default)]
403    pub enabled: bool,
404    #[serde(default = "deny")]
405    pub default_permission: String,
406    #[serde(default, deserialize_with = "null_as_empty")]
407    pub rules: Vec<AclRuleDoc>,
408    #[serde(default, deserialize_with = "null_as_empty")]
409    pub subjects: Vec<AclSubjectDoc>,
410    #[serde(default, deserialize_with = "null_as_empty")]
411    pub policies: Vec<AclPolicyDoc>,
412}
413
414fn deny() -> String {
415    "deny".to_string()
416}
417
418/// zenoh serializes an absent list as `null` (`rules: Option<Vec<_>>`,
419/// `flows: Option<NEVec<_>>`), and serde's `default` covers a *missing*
420/// field only — a `null` into a `Vec` is an error. Read both as empty.
421fn null_as_empty<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
422where
423    D: serde::Deserializer<'de>,
424    T: Default + Deserialize<'de>,
425{
426    Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
427}
428
429/// `AclConfigRule`, as parsed.
430#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
431pub struct AclRuleDoc {
432    pub id: String,
433    #[serde(default, deserialize_with = "null_as_empty")]
434    pub key_exprs: Vec<String>,
435    #[serde(default, deserialize_with = "null_as_empty")]
436    pub messages: Vec<String>,
437    #[serde(default)]
438    pub flows: Option<Vec<String>>,
439    #[serde(default = "deny")]
440    pub permission: String,
441}
442
443/// `AclConfigSubjects`, as parsed — the properties this tool does not plan
444/// (`interfaces`, `usernames`, `link_protocols`) are carried so a check can
445/// say they are there.
446#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
447pub struct AclSubjectDoc {
448    pub id: String,
449    #[serde(default)]
450    pub cert_common_names: Option<Vec<String>>,
451    #[serde(default)]
452    pub zids: Option<Vec<String>>,
453    #[serde(default)]
454    pub interfaces: Option<Vec<String>>,
455    #[serde(default)]
456    pub usernames: Option<Vec<String>>,
457    #[serde(default)]
458    pub link_protocols: Option<Vec<String>>,
459}
460
461/// `AclConfigPolicyEntry`, as parsed.
462#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
463pub struct AclPolicyDoc {
464    #[serde(default)]
465    pub id: Option<String>,
466    #[serde(default, deserialize_with = "null_as_empty")]
467    pub rules: Vec<String>,
468    #[serde(default, deserialize_with = "null_as_empty")]
469    pub subjects: Vec<String>,
470}
471
472// ── --check ───────────────────────────────────────────────────────────────
473
474/// `zenctl acl gen --check --against <router.json5>`: the plan against the
475/// block a router would run.
476#[derive(Debug, Clone, Serialize)]
477pub struct AclCheck {
478    pub base: String,
479    /// Where the observed block came from (RFC 13 §3 O5).
480    pub against: String,
481    pub planned_rules: usize,
482    pub observed_rules: usize,
483    pub planned_subjects: usize,
484    pub observed_subjects: usize,
485    pub findings: Vec<AclFinding>,
486    /// The interest-propagation probe — whether a consumer's declared
487    /// interest reaches the publishers' faces. **Not asked**: the fact is
488    /// observable only from the publisher's side (its matching listener,
489    /// RFC 07 §1), and a check that reads a config file has no publisher to
490    /// ask; faking it from the consumer side would be a verdict on nothing.
491    pub interest_probe: Judgement,
492    pub judgement: Judgement,
493}
494
495/// One way the configured block differs from the plan.
496#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
497pub struct AclFinding {
498    pub kind: AclFindingKind,
499    /// The rule, subject or policy id concerned — or the CN, for
500    /// `unknown_cn`.
501    pub id: String,
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub planned: Option<String>,
504    #[serde(skip_serializing_if = "Option::is_none")]
505    pub observed: Option<String>,
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
509#[serde(rename_all = "snake_case")]
510pub enum AclFindingKind {
511    /// `enabled: false` — the block is there and does nothing.
512    Disabled,
513    DefaultPermissionDiffers,
514    /// Planned, and the block does not carry it.
515    RuleMissing,
516    /// Configured, and the plan does not name it.
517    RuleExtra,
518    /// Same id, different permission, flows, messages or key expressions.
519    RuleDiffers,
520    SubjectMissing,
521    SubjectExtra,
522    /// Same id, different `cert_common_names` or `zids`.
523    SubjectDiffers,
524    /// A configured subject bound by a property this tool never plans
525    /// (`interfaces`, `usernames`, `link_protocols`).
526    SubjectUnplannedProperty,
527    /// A planned policy (rule set × subject set) the block does not carry.
528    PolicyMissing,
529    /// A configured policy the plan does not carry.
530    PolicyExtra,
531    /// A configured CN the enrollment does not know.
532    UnknownCn,
533}
534
535impl AclFindingKind {
536    pub fn as_str(self) -> &'static str {
537        match self {
538            AclFindingKind::Disabled => "disabled",
539            AclFindingKind::DefaultPermissionDiffers => "default_permission_differs",
540            AclFindingKind::RuleMissing => "rule_missing",
541            AclFindingKind::RuleExtra => "rule_extra",
542            AclFindingKind::RuleDiffers => "rule_differs",
543            AclFindingKind::SubjectMissing => "subject_missing",
544            AclFindingKind::SubjectExtra => "subject_extra",
545            AclFindingKind::SubjectDiffers => "subject_differs",
546            AclFindingKind::SubjectUnplannedProperty => "subject_unplanned_property",
547            AclFindingKind::PolicyMissing => "policy_missing",
548            AclFindingKind::PolicyExtra => "policy_extra",
549            AclFindingKind::UnknownCn => "unknown_cn",
550        }
551    }
552}
553
554// ── --explain ─────────────────────────────────────────────────────────────
555
556/// `zenctl acl gen --explain <principal> <key> <message>`: does this
557/// principal hold this grant, via which rules, in which direction.
558#[derive(Debug, Clone, Serialize)]
559pub struct AclExplain {
560    pub principal: String,
561    pub key: String,
562    pub message: AclMessage,
563    pub base: String,
564    /// One answer per direction — a grant that holds on ingress and not on
565    /// egress is exactly the fact-4 failure mode.
566    pub ingress: AclDirection,
567    pub egress: AclDirection,
568}
569
570/// The decision in one direction, with the rules that made it.
571#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
572pub struct AclDirection {
573    pub decision: AclDecision,
574    /// Every rule of the principal's policies whose messages carry the kind
575    /// and whose key expressions include the key, in this direction — deny
576    /// and allow both, so the reader sees what deny beat.
577    #[serde(skip_serializing_if = "Vec::is_empty")]
578    pub via: Vec<AclGrant>,
579    pub reason: String,
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
583#[serde(rename_all = "snake_case")]
584pub enum AclDecision {
585    Allowed,
586    /// A deny rule includes it (deny wins).
587    Denied,
588    /// No rule of the principal's includes it: `default_permission: deny`.
589    DeniedByDefault,
590}
591
592impl AclDecision {
593    pub fn as_str(self) -> &'static str {
594        match self {
595            AclDecision::Allowed => "allowed",
596            AclDecision::Denied => "denied",
597            AclDecision::DeniedByDefault => "denied by default",
598        }
599    }
600}
601
602/// One rule that includes the key for the message kind.
603#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
604pub struct AclGrant {
605    pub rule: String,
606    pub permission: AclPermission,
607    /// The key expression of the rule that includes the key.
608    pub key_expr: String,
609    pub purpose: String,
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use serde_json::json;
616
617    #[test]
618    fn the_enrollment_file_parses_as_documented() {
619        let e: Enrollment = toml::from_str(
620            r#"
621base = "zensight"
622
623[fleet]
624catalog_adv = true
625salt = "example-salt-v1"
626
627[[principal]]
628cn = "h-3fa9c2d41b7e"
629role = "host"
630origin = "h-3fa9c2d41b7e"
631adv = true
632blob_seed = true
633media = true
634
635[[principal]]
636cn = "zensight-console"
637role = "console"
638remote_actions = true
639
640[[principal]]
641cn = "zensight-desired"
642role = "desired-author"
643origin = "@desired"
644"#,
645        )
646        .unwrap();
647        assert_eq!(e.base.as_deref(), Some("zensight"));
648        assert!(e.fleet.catalog_adv);
649        assert_eq!(e.fleet.salt.as_deref(), Some("example-salt-v1"));
650        assert_eq!(e.principal.len(), 3);
651        assert_eq!(e.principal[0].role, Role::Host);
652        assert!(e.principal[0].adv && e.principal[0].blob_seed && e.principal[0].media);
653        assert_eq!(e.principal[1].role, Role::Console);
654        assert!(e.principal[1].remote_actions);
655        assert_eq!(e.principal[2].role, Role::DesiredAuthor);
656        assert_eq!(e.principal[2].origin.as_deref(), Some("@desired"));
657
658        // A typo is a refusal, not a silently ignored intent.
659        let bad = toml::from_str::<Enrollment>(
660            "[[principal]]\ncn = \"x\"\nrole = \"host\"\norigins = \"h-1\"\n",
661        );
662        assert!(bad.is_err());
663        let bad = toml::from_str::<Enrollment>("[[principal]]\ncn = \"x\"\nrole = \"operator\"\n");
664        assert!(bad.is_err());
665    }
666
667    /// The nine message kinds spell exactly what zenoh 1.10's `AclMessage`
668    /// spells (`zenoh-config-1.10.0/src/lib.rs`), and round-trip.
669    #[test]
670    fn the_message_vocabulary_is_zenohs() {
671        let spelled: Vec<serde_json::Value> = AclMessage::ALL
672            .iter()
673            .map(|m| serde_json::to_value(m).unwrap())
674            .collect();
675        assert_eq!(
676            spelled,
677            vec![
678                json!("put"),
679                json!("delete"),
680                json!("declare_subscriber"),
681                json!("query"),
682                json!("declare_queryable"),
683                json!("reply"),
684                json!("liveliness_token"),
685                json!("declare_liveliness_subscriber"),
686                json!("liveliness_query"),
687            ]
688        );
689        for m in AclMessage::ALL {
690            assert_eq!(AclMessage::parse(m.as_str()), Some(m));
691        }
692        assert_eq!(AclMessage::parse("declare_publisher"), None);
693        assert_eq!(
694            serde_json::to_value(AclFlow::Egress).unwrap(),
695            json!("egress")
696        );
697        assert_eq!(
698            serde_json::to_value(AclPermission::Deny).unwrap(),
699            json!("deny")
700        );
701    }
702
703    #[test]
704    fn the_plan_pins_its_shape() {
705        let plan = AclPlan {
706            base: "zensight".into(),
707            default_permission: AclPermission::Deny,
708            registry: Asked::NotAsked,
709            rules: vec![AclRule {
710                id: "host-data-h-3fa9c2d41b7e".into(),
711                permission: AclPermission::Allow,
712                flows: Some(vec![AclFlow::Ingress]),
713                messages: vec![
714                    AclMessage::Put,
715                    AclMessage::Delete,
716                    AclMessage::LivelinessToken,
717                ],
718                key_exprs: vec!["zensight/v1/h-3fa9c2d41b7e/**".into()],
719                purpose: "host-data".into(),
720                cite: "RFC 09 §3 fact 1".into(),
721            }],
722            subjects: vec![AclSubject {
723                id: "h-3fa9c2d41b7e".into(),
724                role: Role::Host,
725                cert_common_names: vec!["h-3fa9c2d41b7e".into()],
726                zids: vec![],
727            }],
728            policies: vec![AclPolicy {
729                id: "h-3fa9c2d41b7e".into(),
730                rules: vec!["host-data-h-3fa9c2d41b7e".into(), "interest-prop".into()],
731                subjects: vec!["h-3fa9c2d41b7e".into()],
732            }],
733            warnings: vec![AclWarning {
734                kind: AclWarningKind::WriteSetNotNarrowed,
735                principal: None,
736                text: "no registry asked".into(),
737                cite: "RFC 13 §3 O4".into(),
738            }],
739            refusals: vec![AclRefusal {
740                principal: "principal #2".into(),
741                reason: "a host needs origin or machine_id".into(),
742                cite: "RFC 03 §4 D6".into(),
743            }],
744        };
745        assert_eq!(
746            serde_json::to_value(&plan).unwrap(),
747            json!({
748                "base": "zensight",
749                "default_permission": "deny",
750                "rules": [{
751                    "id": "host-data-h-3fa9c2d41b7e",
752                    "permission": "allow",
753                    "flows": ["ingress"],
754                    "messages": ["put", "delete", "liveliness_token"],
755                    "key_exprs": ["zensight/v1/h-3fa9c2d41b7e/**"],
756                    "purpose": "host-data",
757                    "cite": "RFC 09 §3 fact 1",
758                }],
759                "subjects": [{
760                    "id": "h-3fa9c2d41b7e",
761                    "role": "host",
762                    "cert_common_names": ["h-3fa9c2d41b7e"],
763                }],
764                "policies": [{
765                    "id": "h-3fa9c2d41b7e",
766                    "rules": ["host-data-h-3fa9c2d41b7e", "interest-prop"],
767                    "subjects": ["h-3fa9c2d41b7e"],
768                }],
769                "warnings": [{
770                    "kind": "write_set_not_narrowed",
771                    "text": "no registry asked",
772                    "cite": "RFC 13 §3 O4",
773                }],
774                "refusals": [{
775                    "principal": "principal #2",
776                    "reason": "a host needs origin or machine_id",
777                    "cite": "RFC 03 §4 D6",
778                }],
779            }),
780            "a not-asked registry is absent; a flowless rule omits `flows`; \
781             empty zids are absent"
782        );
783
784        // With a registry asked and a flowless rule.
785        let mut plan = plan;
786        plan.registry = Asked::Asked(AclRegistryFacts {
787            slices: 2,
788            media_producers: vec!["parallax".into()],
789            blob_producers: vec![],
790            write_procedures: vec!["systemd/action/set".into()],
791        });
792        plan.rules[0].flows = None;
793        plan.warnings.clear();
794        plan.refusals.clear();
795        let v = serde_json::to_value(&plan).unwrap();
796        assert_eq!(
797            v["registry"],
798            json!({
799                "slices": 2,
800                "media_producers": ["parallax"],
801                "blob_producers": [],
802                "write_procedures": ["systemd/action/set"],
803            })
804        );
805        assert!(v["rules"][0].get("flows").is_none());
806        assert!(v.get("warnings").is_none());
807        assert!(v.get("refusals").is_none());
808    }
809
810    #[test]
811    fn the_observed_block_parses_zenohs_shape() {
812        // The shape zenoh's own config carries once loaded: `flows` absent
813        // and `id`-less policies are both legal there.
814        let doc: AclConfigDoc = serde_json::from_value(json!({
815            "enabled": true,
816            "default_permission": "deny",
817            "rules": [{
818                "id": "r1",
819                "key_exprs": ["zensight/v1/**"],
820                "messages": ["put"],
821                "permission": "allow",
822            }],
823            "subjects": [{ "id": "s1", "cert_common_names": ["h-1"] }],
824            "policies": [{ "rules": ["r1"], "subjects": ["s1"] }],
825        }))
826        .unwrap();
827        assert!(doc.enabled);
828        assert_eq!(doc.rules[0].flows, None);
829        // What zenoh's loader hands back for an empty block: nulls, not
830        // absences.
831        let empty: AclConfigDoc = serde_json::from_value(json!({
832            "enabled": false, "default_permission": "deny",
833            "rules": null, "subjects": null, "policies": null,
834        }))
835        .unwrap();
836        assert!(empty.rules.is_empty() && empty.subjects.is_empty() && empty.policies.is_empty());
837        assert_eq!(doc.policies[0].id, None);
838        assert_eq!(
839            doc.subjects[0].cert_common_names.as_deref(),
840            Some(&["h-1".to_string()][..])
841        );
842    }
843
844    #[test]
845    fn the_check_pins_its_shape() {
846        let check = AclCheck {
847            base: "zensight".into(),
848            against: "router.json5".into(),
849            planned_rules: 3,
850            observed_rules: 2,
851            planned_subjects: 1,
852            observed_subjects: 1,
853            findings: vec![AclFinding {
854                kind: AclFindingKind::RuleMissing,
855                id: "interest-prop".into(),
856                planned: Some("egress declare_subscriber …".into()),
857                observed: None,
858            }],
859            interest_probe: Judgement::NotAsked,
860            judgement: Judgement::Established,
861        };
862        assert_eq!(
863            serde_json::to_value(&check).unwrap(),
864            json!({
865                "base": "zensight",
866                "against": "router.json5",
867                "planned_rules": 3,
868                "observed_rules": 2,
869                "planned_subjects": 1,
870                "observed_subjects": 1,
871                "findings": [{
872                    "kind": "rule_missing",
873                    "id": "interest-prop",
874                    "planned": "egress declare_subscriber …",
875                }],
876                "interest_probe": { "answer": "not_asked" },
877                "judgement": { "answer": "established" },
878            })
879        );
880    }
881
882    #[test]
883    fn the_explain_pins_its_shape() {
884        let explain = AclExplain {
885            principal: "zensight-console".into(),
886            key: "zensight/v1/h-3fa9c2d41b7e/@rpc/systemd/action/set".into(),
887            message: AclMessage::Query,
888            base: "zensight".into(),
889            ingress: AclDirection {
890                decision: AclDecision::Denied,
891                via: vec![
892                    AclGrant {
893                        rule: "no-remote-actions".into(),
894                        permission: AclPermission::Deny,
895                        key_expr: "zensight/v1/*/@rpc/*/**/set".into(),
896                        purpose: "no-remote-actions".into(),
897                    },
898                    AclGrant {
899                        rule: "ops-sub".into(),
900                        permission: AclPermission::Allow,
901                        key_expr: "zensight/v1/*/@rpc/**".into(),
902                        purpose: "ops-sub".into(),
903                    },
904                ],
905                reason: "deny wins".into(),
906            },
907            egress: AclDirection {
908                decision: AclDecision::DeniedByDefault,
909                via: vec![],
910                reason: "no rule includes it".into(),
911            },
912        };
913        assert_eq!(
914            serde_json::to_value(&explain).unwrap(),
915            json!({
916                "principal": "zensight-console",
917                "key": "zensight/v1/h-3fa9c2d41b7e/@rpc/systemd/action/set",
918                "message": "query",
919                "base": "zensight",
920                "ingress": {
921                    "decision": "denied",
922                    "via": [
923                        {
924                            "rule": "no-remote-actions",
925                            "permission": "deny",
926                            "key_expr": "zensight/v1/*/@rpc/*/**/set",
927                            "purpose": "no-remote-actions",
928                        },
929                        {
930                            "rule": "ops-sub",
931                            "permission": "allow",
932                            "key_expr": "zensight/v1/*/@rpc/**",
933                            "purpose": "ops-sub",
934                        },
935                    ],
936                    "reason": "deny wins",
937                },
938                "egress": {
939                    "decision": "denied_by_default",
940                    "reason": "no rule includes it",
941                },
942            })
943        );
944    }
945}