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