Skip to main content

laser_wire/
authz.rs

1use crate::codes::*;
2use crate::error::InvalidError;
3use crate::limits::MAX_ROLE_NAME_BYTES;
4use serde::{Deserialize, Serialize};
5
6/// Whether a grant permits or forbids. `Deny` always wins over `Allow`.
7#[derive(
8    Clone,
9    Copy,
10    Debug,
11    Default,
12    PartialEq,
13    Eq,
14    Serialize,
15    Deserialize,
16    strum::Display,
17    strum::EnumString,
18    strum::VariantArray,
19)]
20#[strum(serialize_all = "snake_case")]
21#[serde(rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum Effect {
24    #[default]
25    Allow,
26    Deny,
27}
28
29/// The managed surface a grant applies to. Maps to the command bands, so a grant
30/// on `Kv` is orthogonal to one on `Projection`.
31#[derive(
32    Clone,
33    Copy,
34    Debug,
35    PartialEq,
36    Eq,
37    Hash,
38    Serialize,
39    Deserialize,
40    strum::Display,
41    strum::EnumString,
42    strum::VariantArray,
43)]
44#[strum(serialize_all = "snake_case")]
45#[serde(rename_all = "snake_case")]
46#[non_exhaustive]
47pub enum Feature {
48    Kv,
49    Memory,
50    Projection,
51    Fork,
52    Graph,
53    Query,
54    Agent,
55    Workflow,
56    /// Administration of the authorization layer itself (defining roles and
57    /// binding them). Gated by `authz:admin`; never derived from a command code.
58    Authz,
59    /// A feature name a newer peer used that this build does not know. An unknown
60    /// `feature` string decodes here instead of failing the whole grant set, and
61    /// it matches no request (requests only ever carry a known feature), so an
62    /// unrecognized capability is inert - default-deny, never a silent allow.
63    /// Displays as `unrecognized` (a Display that panicked would let one foreign
64    /// grant crash any UI that renders a grant set), and the string parses back
65    /// into this same deny sink.
66    #[serde(other)]
67    Unrecognized,
68}
69
70/// The verb a grant permits, derived from the command code by [`feature_action`].
71#[derive(
72    Clone,
73    Copy,
74    Debug,
75    PartialEq,
76    Eq,
77    Hash,
78    Serialize,
79    Deserialize,
80    strum::Display,
81    strum::EnumString,
82    strum::VariantArray,
83)]
84#[strum(serialize_all = "snake_case")]
85#[serde(rename_all = "snake_case")]
86#[non_exhaustive]
87pub enum Action {
88    Read,
89    Write,
90    Delete,
91    Admin,
92    /// An action name a newer peer used that this build does not know. Decodes
93    /// here rather than failing the grant set, and matches no request, so an
94    /// unrecognized action is inert - default-deny. Displays as `unrecognized`
95    /// and parses back into this same deny sink, never a panic.
96    #[serde(other)]
97    Unrecognized,
98}
99
100/// How a [`ResourcePattern`] matches a request's resource selector.
101#[derive(
102    Clone,
103    Copy,
104    Debug,
105    Default,
106    PartialEq,
107    Eq,
108    Serialize,
109    Deserialize,
110    strum::Display,
111    strum::EnumString,
112    strum::VariantArray,
113)]
114#[strum(serialize_all = "snake_case")]
115#[serde(rename_all = "snake_case")]
116#[non_exhaustive]
117pub enum ResourceKind {
118    /// The whole feature, ignoring `value` (the absent-pattern default).
119    #[default]
120    All,
121    /// One exact resource name.
122    Literal,
123    /// Every resource name under a prefix.
124    Prefix,
125}
126
127/// A resource selector on a grant: literal, prefixed, or the whole feature.
128#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
129pub struct ResourcePattern {
130    #[serde(default)]
131    pub kind: ResourceKind,
132    #[serde(default, skip_serializing_if = "String::is_empty")]
133    pub value: String,
134}
135
136impl ResourcePattern {
137    /// The whole-feature pattern.
138    pub fn all() -> Self {
139        Self::default()
140    }
141
142    /// An exact-name pattern.
143    pub fn literal(value: impl Into<String>) -> Self {
144        Self {
145            kind: ResourceKind::Literal,
146            value: value.into(),
147        }
148    }
149
150    /// A prefix pattern (every name under `value`).
151    pub fn prefix(value: impl Into<String>) -> Self {
152        Self {
153            kind: ResourceKind::Prefix,
154            value: value.into(),
155        }
156    }
157
158    /// Whether `resource` (the selector decoded from a request) matches. An
159    /// unkeyed request (`None`) matches only a whole-feature pattern.
160    pub fn matches(&self, resource: Option<&str>) -> bool {
161        match (self.kind, resource) {
162            (ResourceKind::All, _) => true,
163            (ResourceKind::Literal, Some(r)) => r == self.value,
164            (ResourceKind::Prefix, Some(r)) => r.starts_with(&self.value),
165            (_, None) => false,
166        }
167    }
168}
169
170/// One capability grant: an effect on a `feature:action`, optionally scoped to a
171/// resource pattern.
172#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
173pub struct Grant {
174    pub effect: Effect,
175    pub feature: Feature,
176    pub action: Action,
177    #[serde(default)]
178    pub resource: ResourcePattern,
179}
180
181/// A named set of grants, bound to users. A user's effective capability is the
182/// union of the grants of every bound role, minus any matching deny.
183#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
184pub struct Role {
185    pub name: String,
186    pub grants: Vec<Grant>,
187}
188
189/// The roles bound to one user (by the server-stamped `user_id`).
190#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
191pub struct RoleBinding {
192    pub user_id: u32,
193    pub roles: Vec<String>,
194}
195
196/// The canonical role-name rule, shared by the SDK, the server, and the
197/// console so a name accepted by one tier is never rejected by the next. A
198/// valid name is non-empty, at most [`MAX_ROLE_NAME_BYTES`] bytes, and made
199/// only of ASCII letters, digits, `-`, `_`, and `.`. Enforced on define and
200/// bind, never on replay: a journaled role loads regardless, so tightening the
201/// rule cannot strand existing state.
202pub fn validate_role_name(name: &str) -> Result<(), InvalidError> {
203    crate::validate::validate_safelisted_name("role name", name, MAX_ROLE_NAME_BYTES)
204}
205
206/// The `(feature, action)` a managed command code authorizes against. `None` for
207/// a code with no capability semantics (hello, backend hello, client metadata,
208/// batch, and the authz band itself), which is gated another way.
209pub fn feature_action(code: u32) -> Option<(Feature, Action)> {
210    let pair = match code {
211        AGDX_QUERY_CODE => (Feature::Query, Action::Read),
212        AGDX_GET_PROJECTION_CODE
213        | AGDX_LIST_PROJECTIONS_CODE
214        | AGDX_GET_SCHEMA_CODE
215        | AGDX_LIST_SCHEMAS_CODE
216        | AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
217        AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
218        AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
219            (Feature::Kv, Action::Read)
220        }
221        AGDX_KV_SET_CODE
222        | AGDX_KV_CAS_CODE
223        | AGDX_KV_CAS_FENCED_CODE
224        | AGDX_KV_PATCH_CODE
225        | AGDX_KV_EXPIRE_CODE
226        | AGDX_KV_COPY_CODE
227        | AGDX_KV_MOVE_CODE
228        | AGDX_KV_LEASE_CODE
229        | AGDX_KV_RELEASE_CODE => (Feature::Kv, Action::Write),
230        AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
231        AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
232        AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
233        AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
234        AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
235        AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
236        AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
237        AGDX_AGENT_STATUS_CODE | AGDX_AGENT_LIST_CODE => (Feature::Agent, Action::Read),
238        AGDX_AGENT_SUBMIT_CODE => (Feature::Agent, Action::Write),
239        AGDX_AGENT_CANCEL_CODE => (Feature::Agent, Action::Delete),
240        _ => return None,
241    };
242    Some(pair)
243}
244
245/// The number of [`Action`] variants (including the `Unrecognized` catch-all):
246/// the stride of the shared coarse-capability bitmask layout ([`action_index`]).
247/// The stride must cover every action so one feature's last action bit never
248/// collides with the next feature's first.
249pub const ACTION_COUNT: usize = 5;
250
251/// The bit index of a `(feature, action)` in the coarse-capability bitmask, a
252/// pure function shared by every enforcer so the fork and the plane cannot drift.
253/// `Feature`/`Action` are `VariantArray` enums, so the ordinal is stable per wire
254/// revision.
255pub fn action_index(feature: Feature, action: Action) -> usize {
256    feature as usize * ACTION_COUNT + action as usize
257}
258
259// The coarse-capability bitmask every enforcer shares is a `u64`, so every
260// `(feature, action)` bit index must fit in 64 bits. Adding features or actions
261// past that ceiling is a compile error here, not a silent runtime aliasing of two
262// distinct capabilities onto one bit. `ACTION_COUNT` must also stay the true
263// `Action` variant count, or `action_index`'s stride would skip or overlap rows.
264const _: () = {
265    assert!(
266        <Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
267        "ACTION_COUNT must equal the number of Action variants"
268    );
269    assert!(
270        <Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 64,
271        "authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 64 bits"
272    );
273};
274
275/// Whether `grants` permit `(feature, action)` on `resource`, deny-wins. An
276/// empty set permits nothing (there is no allow to match). `resource` is the
277/// selector decoded from a request, or `None` for an unkeyed op.
278pub fn grants_allow(
279    grants: &[Grant],
280    feature: Feature,
281    action: Action,
282    resource: Option<&str>,
283) -> bool {
284    let mut allowed = false;
285    for grant in grants {
286        if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
287            match grant.effect {
288                Effect::Deny => return false,
289                Effect::Allow => allowed = true,
290            }
291        }
292    }
293    allowed
294}
295
296/// The on-behalf-of check: an agent acting for a user is permitted an op only
297/// when both its own grants and the invoking user's grants permit it. The agent
298/// can never exceed the user who invoked it (permission intersection).
299pub fn delegated_allow(
300    agent: &[Grant],
301    user: &[Grant],
302    feature: Feature,
303    action: Action,
304    resource: Option<&str>,
305) -> bool {
306    grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
307}
308
309/// Request the caller's own effective capabilities.
310#[derive(Clone, Debug, Serialize, Deserialize)]
311pub struct WhoamiReq {
312    pub v: u32,
313}
314
315/// The caller's bound roles and their flattened grants.
316#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
317pub struct WhoamiReply {
318    pub v: u32,
319    pub roles: Vec<String>,
320    pub grants: Vec<Grant>,
321}
322
323/// Request to list roles, optionally filtered. Absent filters list every role,
324/// the same bounded-registry browse as `ListProjections`.
325#[derive(Clone, Debug, Serialize, Deserialize)]
326pub struct ListRolesReq {
327    pub v: u32,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub name_prefix: Option<String>,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub search: Option<String>,
332}
333
334/// Every matching role with its full grant set.
335#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
336pub struct ListRolesReply {
337    pub v: u32,
338    pub roles: Vec<Role>,
339}
340
341/// Request one role by name.
342#[derive(Clone, Debug, Serialize, Deserialize)]
343pub struct GetRoleReq {
344    pub v: u32,
345    pub name: String,
346}
347
348/// Request one user's bound role names.
349#[derive(Clone, Debug, Serialize, Deserialize)]
350pub struct GetBindingsReq {
351    pub v: u32,
352    pub user_id: u32,
353}
354
355/// One user's bound role names.
356#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
357pub struct BindingsReply {
358    pub v: u32,
359    pub roles: Vec<String>,
360}
361
362/// Define or replace a role (upsert, carries the full grant set).
363#[derive(Clone, Debug, Serialize, Deserialize)]
364pub struct DefineRoleReq {
365    pub v: u32,
366    pub role: Role,
367}
368
369/// Delete a role by name.
370#[derive(Clone, Debug, Serialize, Deserialize)]
371pub struct DeleteRoleReq {
372    pub v: u32,
373    pub name: String,
374}
375
376/// Bind roles to a user (replace the user's whole role set).
377#[derive(Clone, Debug, Serialize, Deserialize)]
378pub struct BindRolesReq {
379    pub v: u32,
380    pub user_id: u32,
381    pub roles: Vec<String>,
382    /// Compare-and-swap precondition: apply only if the binding's current
383    /// revision equals this. Absent means an unconditional replace (the prior
384    /// behavior), so a caller opts into optimistic concurrency. A mismatch fails
385    /// with [`AuthzError::Conflict`]. Skip-none, so a request without it stays
386    /// byte-identical.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub expect_revision: Option<u64>,
389}
390
391/// Which authorization subject an [`AuthzHistoryReq`] reads the change log of.
392#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case")]
394pub enum AuthzSubject {
395    /// One role by name.
396    Role(String),
397    /// One user's bindings.
398    Binding { user_id: u32 },
399    /// Every authorization change.
400    All,
401}
402
403/// Read the authorization change history for a subject, paged by revision. The
404/// audit surface the first compliance conversation opens: who granted what, when.
405#[derive(Clone, Debug, Serialize, Deserialize)]
406pub struct AuthzHistoryReq {
407    pub v: u32,
408    pub subject: AuthzSubject,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub after_revision: Option<u64>,
411    pub limit: u32,
412}
413
414/// What an [`AuthzEvent`] recorded.
415#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(rename_all = "snake_case")]
417pub enum AuthzEventKind {
418    /// A role was defined or replaced.
419    RoleDefined(String),
420    /// A role was deleted.
421    RoleDeleted(String),
422    /// A user's role set was rebound.
423    RolesBound { user_id: u32, roles: Vec<String> },
424}
425
426/// One recorded authorization change: its revision, who made it, when, and what.
427#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
428pub struct AuthzEvent {
429    pub revision: u64,
430    pub actor: String,
431    pub at_micros: u64,
432    pub op: AuthzEventKind,
433}
434
435/// A page of authorization change history.
436#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
437pub struct AuthzHistoryReply {
438    pub v: u32,
439    pub events: Vec<AuthzEvent>,
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub next_after_revision: Option<u64>,
442}
443
444/// Reply to any authorization command, shaped per request.
445#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
446#[non_exhaustive]
447pub enum AuthzReply {
448    /// A mutating command applied (`define_role`, `delete_role`, `bind_roles`).
449    Ok,
450    /// `whoami`: the caller's effective capabilities.
451    Whoami(WhoamiReply),
452    /// `list_roles`: every matching role.
453    Roles(ListRolesReply),
454    /// `get_role`: the role with the requested name, or `None`.
455    Role(Option<Role>),
456    /// `get_bindings`: one user's bound role names.
457    Bindings(BindingsReply),
458    /// `history`: a page of the authorization change log.
459    History(AuthzHistoryReply),
460    Err(AuthzError),
461}
462
463/// An authorization command failure.
464#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
465#[non_exhaustive]
466pub enum AuthzError {
467    #[error("authz not supported: {0}")]
468    Unsupported(String),
469    #[error("unauthorized")]
470    Unauthorized,
471    #[error("unknown role: {0}")]
472    UnknownRole(String),
473    /// A define or bind named a role that fails [`validate_role_name`].
474    #[error("invalid role name: {0}")]
475    InvalidName(String),
476    /// A compare-and-swap bind lost the precondition: the binding's current
477    /// revision is not the one the request expected, so a concurrent admin got
478    /// there first. The caller re-reads and retries.
479    #[error("revision conflict: current is {current_revision}")]
480    Conflict { current_revision: u64 },
481    #[error("unsupported authz op version (expected {expected}, got {got})")]
482    Version { expected: u32, got: u32 },
483}
484
485#[cfg(all(test, feature = "cbor"))]
486mod tests {
487    use super::*;
488    use crate::framing::{decode_named, encode_named};
489
490    #[test]
491    fn given_a_role_when_round_tripped_then_should_preserve_grants() {
492        let role = Role {
493            name: "kv-reader".to_string(),
494            grants: vec![
495                Grant {
496                    effect: Effect::Allow,
497                    feature: Feature::Kv,
498                    action: Action::Read,
499                    resource: ResourcePattern::prefix("agent-abc/"),
500                },
501                Grant {
502                    effect: Effect::Deny,
503                    feature: Feature::Kv,
504                    action: Action::Read,
505                    resource: ResourcePattern::literal("agent-abc/secret"),
506                },
507            ],
508        };
509        let bytes = encode_named(&role).expect("role serializes");
510        let back: Role = decode_named(&bytes).expect("role deserializes");
511        assert_eq!(back, role);
512    }
513
514    #[test]
515    fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
516        assert!(validate_role_name("kv-reader").is_ok());
517        assert!(validate_role_name("ops.admin_2").is_ok());
518        assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
519        assert!(validate_role_name("").is_err(), "empty");
520        assert!(validate_role_name("bad name").is_err(), "space");
521        assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
522        assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
523    }
524
525    #[test]
526    fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
527        assert!(ResourcePattern::all().matches(Some("anything")));
528        assert!(ResourcePattern::all().matches(None));
529        assert!(ResourcePattern::literal("ns").matches(Some("ns")));
530        assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
531        assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
532        assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
533        // Unkeyed requests are whole-surface operations, so scoped grants must
534        // not widen to them.
535        assert!(!ResourcePattern::literal("ns").matches(None));
536        assert!(!ResourcePattern::prefix("agent-").matches(None));
537    }
538
539    #[test]
540    fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
541        let allow = |feature, action, resource| Grant {
542            effect: Effect::Allow,
543            feature,
544            action,
545            resource,
546        };
547        // Agent may read+write kv anywhere; the user it acts for may only read kv
548        // under `shared/`. The intersection permits only what BOTH allow.
549        let agent = vec![
550            allow(Feature::Kv, Action::Read, ResourcePattern::all()),
551            allow(Feature::Kv, Action::Write, ResourcePattern::all()),
552        ];
553        let user = vec![allow(
554            Feature::Kv,
555            Action::Read,
556            ResourcePattern::prefix("shared/"),
557        )];
558        assert!(delegated_allow(
559            &agent,
560            &user,
561            Feature::Kv,
562            Action::Read,
563            Some("shared/x")
564        ));
565        // Outside the user's prefix: agent alone would allow, the user does not.
566        assert!(!delegated_allow(
567            &agent,
568            &user,
569            Feature::Kv,
570            Action::Read,
571            Some("private/x")
572        ));
573        // The user cannot write at all, so the agent cannot write on its behalf.
574        assert!(!delegated_allow(
575            &agent,
576            &user,
577            Feature::Kv,
578            Action::Write,
579            Some("shared/x")
580        ));
581        // An empty grant set permits nothing.
582        assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
583    }
584
585    #[test]
586    fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
587        assert_eq!(
588            feature_action(AGDX_KV_GET_CODE),
589            Some((Feature::Kv, Action::Read))
590        );
591        assert_eq!(
592            feature_action(AGDX_KV_SET_CODE),
593            Some((Feature::Kv, Action::Write))
594        );
595        assert_eq!(
596            feature_action(AGDX_KV_DELETE_CODE),
597            Some((Feature::Kv, Action::Delete))
598        );
599        assert_eq!(
600            feature_action(AGDX_REGISTER_SCHEMA_CODE),
601            Some((Feature::Projection, Action::Admin))
602        );
603        assert_eq!(
604            feature_action(AGDX_QUERY_CODE),
605            Some((Feature::Query, Action::Read))
606        );
607        assert_eq!(
608            feature_action(AGDX_GRAPH_UPSERT_CODE),
609            Some((Feature::Graph, Action::Write))
610        );
611        // No capability semantics: hello, batch, and the authz band self-gate.
612        assert_eq!(feature_action(AGDX_HELLO_CODE), None);
613        assert_eq!(feature_action(AGDX_BATCH_CODE), None);
614        assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
615    }
616
617    #[test]
618    fn given_feature_action_pairs_when_indexed_then_should_fit_a_u64_mask() {
619        use strum::VariantArray;
620        let mut seen = std::collections::HashSet::new();
621        for &feature in Feature::VARIANTS {
622            for &action in Action::VARIANTS {
623                let index = action_index(feature, action);
624                assert!(index < 64, "index {index} must fit a u64 mask");
625                assert!(seen.insert(index), "index {index} collided");
626            }
627        }
628    }
629
630    #[test]
631    fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
632        // A grant naming a feature and action a newer peer added decodes to the
633        // Unrecognized catch-all rather than failing the whole grant set.
634        let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
635        let grant: Grant =
636            serde_json::from_str(json).expect("an unknown feature/action still decodes");
637        assert_eq!(grant.feature, Feature::Unrecognized);
638        assert_eq!(grant.action, Action::Unrecognized);
639        // An unrecognized capability matches no real request (requests are only
640        // ever classified into known features and actions by `feature_action`),
641        // so it is inert: default-deny, never a silent allow.
642        assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
643        // Displaying the deny sink must never panic (a panicking Display let one
644        // foreign grant crash a UI rendering a grant set) and the string parses
645        // back into the same sink.
646        assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
647        assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
648        assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
649        assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
650    }
651
652    #[test]
653    fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
654        let reply = AuthzReply::Whoami(WhoamiReply {
655            v: AUTHZ_OP_VERSION,
656            roles: vec!["admin".to_string()],
657            grants: vec![Grant {
658                effect: Effect::Allow,
659                feature: Feature::Kv,
660                action: Action::Write,
661                resource: ResourcePattern::all(),
662            }],
663        });
664        let bytes = encode_named(&reply).expect("reply serializes");
665        let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
666        assert_eq!(back, reply);
667    }
668}