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    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub mutation_id: Option<String>,
369}
370
371/// Delete a role by name.
372#[derive(Clone, Debug, Serialize, Deserialize)]
373pub struct DeleteRoleReq {
374    pub v: u32,
375    pub name: String,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub mutation_id: Option<String>,
378}
379
380/// Bind roles to a user (replace the user's whole role set).
381#[derive(Clone, Debug, Serialize, Deserialize)]
382pub struct BindRolesReq {
383    pub v: u32,
384    pub user_id: u32,
385    pub roles: Vec<String>,
386    /// Compare-and-swap precondition: apply only if the binding's current
387    /// revision equals this. Absent means an unconditional replace (the prior
388    /// behavior), so a caller opts into optimistic concurrency. A mismatch fails
389    /// with [`AuthzError::Conflict`]. Skip-none, so a request without it stays
390    /// byte-identical.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub expect_revision: Option<u64>,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub mutation_id: Option<String>,
395}
396
397/// Which authorization subject an [`AuthzHistoryReq`] reads the change log of.
398#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(rename_all = "snake_case")]
400pub enum AuthzSubject {
401    /// One role by name.
402    Role(String),
403    /// One user's bindings.
404    Binding { user_id: u32 },
405    /// Every authorization change.
406    All,
407}
408
409/// Read the authorization change history for a subject, paged by revision. The
410/// audit surface the first compliance conversation opens: who granted what, when.
411#[derive(Clone, Debug, Serialize, Deserialize)]
412pub struct AuthzHistoryReq {
413    pub v: u32,
414    pub subject: AuthzSubject,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub after_revision: Option<u64>,
417    pub limit: u32,
418}
419
420/// What an [`AuthzEvent`] recorded.
421#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(rename_all = "snake_case")]
423pub enum AuthzEventKind {
424    /// A role was defined or replaced.
425    RoleDefined(String),
426    /// A role was deleted.
427    RoleDeleted(String),
428    /// A user's role set was rebound.
429    RolesBound { user_id: u32, roles: Vec<String> },
430}
431
432/// One recorded authorization change: its revision, who made it, when, and what.
433#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
434pub struct AuthzEvent {
435    pub revision: u64,
436    pub actor: String,
437    pub at_micros: u64,
438    pub op: AuthzEventKind,
439}
440
441/// A page of authorization change history.
442#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
443pub struct AuthzHistoryReply {
444    pub v: u32,
445    pub events: Vec<AuthzEvent>,
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub next_after_revision: Option<u64>,
448}
449
450/// Reply to any authorization command, shaped per request.
451#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
452#[non_exhaustive]
453pub enum AuthzReply {
454    /// A mutating command applied (`define_role`, `delete_role`, `bind_roles`).
455    Ok,
456    /// `whoami`: the caller's effective capabilities.
457    Whoami(WhoamiReply),
458    /// `list_roles`: every matching role.
459    Roles(ListRolesReply),
460    /// `get_role`: the role with the requested name, or `None`.
461    Role(Option<Role>),
462    /// `get_bindings`: one user's bound role names.
463    Bindings(BindingsReply),
464    /// `history`: a page of the authorization change log.
465    History(AuthzHistoryReply),
466    Err(AuthzError),
467}
468
469/// An authorization command failure.
470#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
471#[non_exhaustive]
472pub enum AuthzError {
473    #[error("authz not supported: {0}")]
474    Unsupported(String),
475    #[error("unauthorized")]
476    Unauthorized,
477    #[error("unknown role: {0}")]
478    UnknownRole(String),
479    /// A define or bind named a role that fails [`validate_role_name`].
480    #[error("invalid role name: {0}")]
481    InvalidName(String),
482    /// A compare-and-swap bind lost the precondition: the binding's current
483    /// revision is not the one the request expected, so a concurrent admin got
484    /// there first. The caller re-reads and retries.
485    #[error("revision conflict: current is {current_revision}")]
486    Conflict { current_revision: u64 },
487    #[error("unsupported authz op version (expected {expected}, got {got})")]
488    Version { expected: u32, got: u32 },
489}
490
491#[cfg(all(test, feature = "cbor"))]
492mod tests {
493    use super::*;
494    use crate::framing::{decode_named, encode_named};
495
496    #[test]
497    fn given_a_role_when_round_tripped_then_should_preserve_grants() {
498        let role = Role {
499            name: "kv-reader".to_string(),
500            grants: vec![
501                Grant {
502                    effect: Effect::Allow,
503                    feature: Feature::Kv,
504                    action: Action::Read,
505                    resource: ResourcePattern::prefix("agent-abc/"),
506                },
507                Grant {
508                    effect: Effect::Deny,
509                    feature: Feature::Kv,
510                    action: Action::Read,
511                    resource: ResourcePattern::literal("agent-abc/secret"),
512                },
513            ],
514        };
515        let bytes = encode_named(&role).expect("role serializes");
516        let back: Role = decode_named(&bytes).expect("role deserializes");
517        assert_eq!(back, role);
518    }
519
520    #[test]
521    fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
522        assert!(validate_role_name("kv-reader").is_ok());
523        assert!(validate_role_name("ops.admin_2").is_ok());
524        assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
525        assert!(validate_role_name("").is_err(), "empty");
526        assert!(validate_role_name("bad name").is_err(), "space");
527        assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
528        assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
529    }
530
531    #[test]
532    fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
533        assert!(ResourcePattern::all().matches(Some("anything")));
534        assert!(ResourcePattern::all().matches(None));
535        assert!(ResourcePattern::literal("ns").matches(Some("ns")));
536        assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
537        assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
538        assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
539        // Unkeyed requests are whole-surface operations, so scoped grants must
540        // not widen to them.
541        assert!(!ResourcePattern::literal("ns").matches(None));
542        assert!(!ResourcePattern::prefix("agent-").matches(None));
543    }
544
545    #[test]
546    fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
547        let allow = |feature, action, resource| Grant {
548            effect: Effect::Allow,
549            feature,
550            action,
551            resource,
552        };
553        // Agent may read+write kv anywhere. The user it acts for may only read kv
554        // under `shared/`. The intersection permits only what BOTH allow.
555        let agent = vec![
556            allow(Feature::Kv, Action::Read, ResourcePattern::all()),
557            allow(Feature::Kv, Action::Write, ResourcePattern::all()),
558        ];
559        let user = vec![allow(
560            Feature::Kv,
561            Action::Read,
562            ResourcePattern::prefix("shared/"),
563        )];
564        assert!(delegated_allow(
565            &agent,
566            &user,
567            Feature::Kv,
568            Action::Read,
569            Some("shared/x")
570        ));
571        // Outside the user's prefix: agent alone would allow, the user does not.
572        assert!(!delegated_allow(
573            &agent,
574            &user,
575            Feature::Kv,
576            Action::Read,
577            Some("private/x")
578        ));
579        // The user cannot write at all, so the agent cannot write on its behalf.
580        assert!(!delegated_allow(
581            &agent,
582            &user,
583            Feature::Kv,
584            Action::Write,
585            Some("shared/x")
586        ));
587        // An empty grant set permits nothing.
588        assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
589    }
590
591    #[test]
592    fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
593        assert_eq!(
594            feature_action(AGDX_KV_GET_CODE),
595            Some((Feature::Kv, Action::Read))
596        );
597        assert_eq!(
598            feature_action(AGDX_KV_SET_CODE),
599            Some((Feature::Kv, Action::Write))
600        );
601        assert_eq!(
602            feature_action(AGDX_KV_DELETE_CODE),
603            Some((Feature::Kv, Action::Delete))
604        );
605        assert_eq!(
606            feature_action(AGDX_REGISTER_SCHEMA_CODE),
607            Some((Feature::Projection, Action::Admin))
608        );
609        assert_eq!(
610            feature_action(AGDX_QUERY_CODE),
611            Some((Feature::Query, Action::Read))
612        );
613        assert_eq!(
614            feature_action(AGDX_GRAPH_UPSERT_CODE),
615            Some((Feature::Graph, Action::Write))
616        );
617        // No capability semantics: hello, batch, and the authz band self-gate.
618        assert_eq!(feature_action(AGDX_HELLO_CODE), None);
619        assert_eq!(feature_action(AGDX_BATCH_CODE), None);
620        assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
621    }
622
623    #[test]
624    fn given_feature_action_pairs_when_indexed_then_should_fit_a_u64_mask() {
625        use strum::VariantArray;
626        let mut seen = std::collections::HashSet::new();
627        for &feature in Feature::VARIANTS {
628            for &action in Action::VARIANTS {
629                let index = action_index(feature, action);
630                assert!(index < 64, "index {index} must fit a u64 mask");
631                assert!(seen.insert(index), "index {index} collided");
632            }
633        }
634    }
635
636    #[test]
637    fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
638        // A grant naming a feature and action a newer peer added decodes to the
639        // Unrecognized catch-all rather than failing the whole grant set.
640        let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
641        let grant: Grant =
642            serde_json::from_str(json).expect("an unknown feature/action still decodes");
643        assert_eq!(grant.feature, Feature::Unrecognized);
644        assert_eq!(grant.action, Action::Unrecognized);
645        // An unrecognized capability matches no real request (requests are only
646        // ever classified into known features and actions by `feature_action`),
647        // so it is inert: default-deny, never a silent allow.
648        assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
649        // Displaying the deny sink must never panic (a panicking Display let one
650        // foreign grant crash a UI rendering a grant set) and the string parses
651        // back into the same sink.
652        assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
653        assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
654        assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
655        assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
656    }
657
658    #[test]
659    fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
660        let reply = AuthzReply::Whoami(WhoamiReply {
661            v: AUTHZ_OP_VERSION,
662            roles: vec!["admin".to_string()],
663            grants: vec![Grant {
664                effect: Effect::Allow,
665                feature: Feature::Kv,
666                action: Action::Write,
667                resource: ResourcePattern::all(),
668            }],
669        });
670        let bytes = encode_named(&reply).expect("reply serializes");
671        let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
672        assert_eq!(back, reply);
673    }
674}