Skip to main content

laser_wire/
authz.rs

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