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_GET_PROJECTION_CODE
312        | AGDX_LIST_PROJECTIONS_CODE
313        | AGDX_GET_SCHEMA_CODE
314        | AGDX_LIST_SCHEMAS_CODE
315        | AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
316        AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
317        AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
318            (Feature::Kv, Action::Read)
319        }
320        // A fenced CAS additionally requires `kv_fence:read` on its
321        // coordination namespace ([`crate::kv::KvCasFenced::fence_namespace`]);
322        // this map carries the primary target-namespace grant and the enforcer
323        // checks the fence grant beside it.
324        AGDX_KV_SET_CODE
325        | AGDX_KV_CAS_CODE
326        | AGDX_KV_CAS_FENCED_CODE
327        | AGDX_KV_PATCH_CODE
328        | AGDX_KV_EXPIRE_CODE
329        | AGDX_KV_COPY_CODE
330        | AGDX_KV_MOVE_CODE => (Feature::Kv, Action::Write),
331        // Lease lifecycle is its own feature: a `kv:write` grant on a
332        // namespace must never imply acquiring, renewing, or releasing the
333        // lease that fences it. `admin` is the house action for a surface's
334        // privileged op (`projection:admin`, `fork:admin`), so lease control
335        // is `kv_lease:admin`, distinct from `kv:admin` by the feature alone.
336        AGDX_KV_LEASE_CODE | AGDX_KV_LEASE_RENEW_CODE | AGDX_KV_RELEASE_CODE => {
337            (Feature::KvLease, Action::Admin)
338        }
339        AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
340        AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
341        AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
342        AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
343        AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
344        AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
345        AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
346        AGDX_AGENT_STATUS_CODE | AGDX_AGENT_LIST_CODE => (Feature::Agent, Action::Read),
347        AGDX_AGENT_SUBMIT_CODE => (Feature::Agent, Action::Write),
348        AGDX_AGENT_CANCEL_CODE => (Feature::Agent, Action::Delete),
349        _ => return None,
350    };
351    Some(pair)
352}
353
354/// The number of [`Action`] variants (including the `Unrecognized` catch-all):
355/// the stride of the shared coarse-capability bitmask layout ([`action_index`]).
356/// The stride must cover every action so one feature's last action bit never
357/// collides with the next feature's first. Keep the action set minimal: every
358/// added action costs one mask bit per feature, so new capability semantics
359/// split into a feature (a fresh row) rather than an action (a wider stride).
360pub const ACTION_COUNT: usize = 5;
361
362/// The bit index of a `(feature, action)` in the coarse-capability bitmask, a
363/// pure function shared by every enforcer so the fork and the plane cannot drift.
364/// `Feature`/`Action` are `VariantArray` enums, so the ordinal is stable per wire
365/// revision.
366pub fn action_index(feature: Feature, action: Action) -> usize {
367    feature as usize * ACTION_COUNT + action as usize
368}
369
370// The coarse-capability bitmask every enforcer shares is a `u128` (widened
371// from `u64` when the kv_lease/kv_fence feature split pushed the grid past 64),
372// so every `(feature, action)` bit index must fit in 128 bits. Adding features
373// or actions past that ceiling is a compile error here, not a silent runtime
374// aliasing of two distinct capabilities onto one bit. `ACTION_COUNT` must also
375// stay the true `Action` variant count, or `action_index`'s stride would skip
376// or overlap rows.
377const _: () = {
378    assert!(
379        <Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
380        "ACTION_COUNT must equal the number of Action variants"
381    );
382    assert!(
383        <Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 128,
384        "authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 128 bits"
385    );
386};
387
388/// Whether `grants` permit `(feature, action)` on `resource`, deny-wins. An
389/// empty set permits nothing (there is no allow to match). `resource` is the
390/// selector decoded from a request, or `None` for an unkeyed op.
391pub fn grants_allow(
392    grants: &[Grant],
393    feature: Feature,
394    action: Action,
395    resource: Option<&str>,
396) -> bool {
397    let mut allowed = false;
398    for grant in grants {
399        if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
400            match grant.effect {
401                Effect::Deny => return false,
402                Effect::Allow => allowed = true,
403            }
404        }
405    }
406    allowed
407}
408
409/// The on-behalf-of check: an agent acting for a user is permitted an op only
410/// when both its own grants and the invoking user's grants permit it. The agent
411/// can never exceed the user who invoked it (permission intersection).
412pub fn delegated_allow(
413    agent: &[Grant],
414    user: &[Grant],
415    feature: Feature,
416    action: Action,
417    resource: Option<&str>,
418) -> bool {
419    grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
420}
421
422/// Request the caller's own effective capabilities.
423#[derive(Clone, Debug, Serialize, Deserialize)]
424pub struct WhoamiReq {
425    pub v: u32,
426}
427
428/// The caller's bound roles and their flattened grants.
429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
430pub struct WhoamiReply {
431    pub v: u32,
432    pub roles: Vec<String>,
433    pub grants: Vec<Grant>,
434}
435
436/// Request to list roles, optionally filtered. Absent filters list every role,
437/// the same bounded-registry browse as `ListProjections`.
438#[derive(Clone, Debug, Serialize, Deserialize)]
439pub struct ListRolesReq {
440    pub v: u32,
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    pub name_prefix: Option<String>,
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub search: Option<String>,
445}
446
447/// Every matching role with its full grant set.
448#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
449pub struct ListRolesReply {
450    pub v: u32,
451    pub roles: Vec<Role>,
452}
453
454/// Request one role by name.
455#[derive(Clone, Debug, Serialize, Deserialize)]
456pub struct GetRoleReq {
457    pub v: u32,
458    pub name: String,
459}
460
461/// Request one user's bound role names.
462#[derive(Clone, Debug, Serialize, Deserialize)]
463pub struct GetBindingsReq {
464    pub v: u32,
465    pub user_id: u32,
466}
467
468/// One user's bound role names.
469#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
470pub struct BindingsReply {
471    pub v: u32,
472    pub roles: Vec<String>,
473}
474
475/// Define or replace a role (upsert, carries the full grant set).
476#[derive(Clone, Debug, Serialize, Deserialize)]
477pub struct DefineRoleReq {
478    pub v: u32,
479    pub role: Role,
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub mutation_id: Option<String>,
482}
483
484/// Delete a role by name.
485#[derive(Clone, Debug, Serialize, Deserialize)]
486pub struct DeleteRoleReq {
487    pub v: u32,
488    pub name: String,
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub mutation_id: Option<String>,
491}
492
493/// Bind roles to a user (replace the user's whole role set).
494#[derive(Clone, Debug, Serialize, Deserialize)]
495pub struct BindRolesReq {
496    pub v: u32,
497    pub user_id: u32,
498    pub roles: Vec<String>,
499    /// Compare-and-swap precondition: apply only if the binding's current
500    /// revision equals this. Absent means an unconditional replace (the prior
501    /// behavior), so a caller opts into optimistic concurrency. A mismatch fails
502    /// with [`AuthzError::Conflict`]. Skip-none, so a request without it stays
503    /// byte-identical.
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub expect_revision: Option<u64>,
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub mutation_id: Option<String>,
508}
509
510/// Which authorization subject an [`AuthzHistoryReq`] reads the change log of.
511#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
512#[serde(rename_all = "snake_case")]
513pub enum AuthzSubject {
514    /// One role by name.
515    Role(String),
516    /// One user's bindings.
517    Binding { user_id: u32 },
518    /// Every authorization change.
519    All,
520}
521
522/// Read the authorization change history for a subject, paged by revision. The
523/// audit surface the first compliance conversation opens: who granted what, when.
524#[derive(Clone, Debug, Serialize, Deserialize)]
525pub struct AuthzHistoryReq {
526    pub v: u32,
527    pub subject: AuthzSubject,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub after_revision: Option<u64>,
530    pub limit: u32,
531}
532
533/// What an [`AuthzEvent`] recorded.
534#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
535#[serde(rename_all = "snake_case")]
536pub enum AuthzEventKind {
537    /// A role was defined or replaced.
538    RoleDefined(String),
539    /// A role was deleted.
540    RoleDeleted(String),
541    /// A user's role set was rebound.
542    RolesBound { user_id: u32, roles: Vec<String> },
543}
544
545/// One recorded authorization change: its revision, who made it, when, and what.
546#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
547pub struct AuthzEvent {
548    pub revision: u64,
549    pub actor: String,
550    pub at_micros: u64,
551    pub op: AuthzEventKind,
552}
553
554/// A page of authorization change history.
555#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
556pub struct AuthzHistoryReply {
557    pub v: u32,
558    pub events: Vec<AuthzEvent>,
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub next_after_revision: Option<u64>,
561}
562
563/// Reply to any authorization command, shaped per request.
564#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
565#[non_exhaustive]
566pub enum AuthzReply {
567    /// A mutating command applied (`define_role`, `delete_role`, `bind_roles`).
568    Ok,
569    /// `whoami`: the caller's effective capabilities.
570    Whoami(WhoamiReply),
571    /// `list_roles`: every matching role.
572    Roles(ListRolesReply),
573    /// `get_role`: the role with the requested name, or `None`.
574    Role(Option<Role>),
575    /// `get_bindings`: one user's bound role names.
576    Bindings(BindingsReply),
577    /// `history`: a page of the authorization change log.
578    History(AuthzHistoryReply),
579    Err(AuthzError),
580}
581
582/// An authorization command failure.
583#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
584#[non_exhaustive]
585pub enum AuthzError {
586    #[error("authz not supported: {0}")]
587    Unsupported(String),
588    #[error("unauthorized")]
589    Unauthorized,
590    #[error("unknown role: {0}")]
591    UnknownRole(String),
592    /// A define or bind named a role that fails [`validate_role_name`].
593    #[error("invalid role name: {0}")]
594    InvalidName(String),
595    /// A compare-and-swap bind lost the precondition: the binding's current
596    /// revision is not the one the request expected, so a concurrent admin got
597    /// there first. The caller re-reads and retries.
598    #[error("revision conflict: current is {current_revision}")]
599    Conflict { current_revision: u64 },
600    #[error("unsupported authz op version (expected {expected}, got {got})")]
601    Version { expected: u32, got: u32 },
602}
603
604#[cfg(all(test, feature = "cbor"))]
605mod tests {
606    use super::*;
607    use crate::framing::{decode_named, encode_named};
608
609    #[test]
610    fn given_a_role_when_round_tripped_then_should_preserve_grants() {
611        let role = Role {
612            name: "kv-reader".to_string(),
613            grants: vec![
614                Grant {
615                    effect: Effect::Allow,
616                    feature: Feature::Kv,
617                    action: Action::Read,
618                    resource: ResourcePattern::prefix("agent-abc/"),
619                },
620                Grant {
621                    effect: Effect::Deny,
622                    feature: Feature::Kv,
623                    action: Action::Read,
624                    resource: ResourcePattern::literal("agent-abc/secret"),
625                },
626            ],
627        };
628        let bytes = encode_named(&role).expect("role serializes");
629        let back: Role = decode_named(&bytes).expect("role deserializes");
630        assert_eq!(back, role);
631    }
632
633    #[test]
634    fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
635        assert!(validate_role_name("kv-reader").is_ok());
636        assert!(validate_role_name("ops.admin_2").is_ok());
637        assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
638        assert!(validate_role_name("").is_err(), "empty");
639        assert!(validate_role_name("bad name").is_err(), "space");
640        assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
641        assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
642    }
643
644    #[test]
645    fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
646        assert!(ResourcePattern::all().matches(Some("anything")));
647        assert!(ResourcePattern::all().matches(None));
648        assert!(ResourcePattern::literal("ns").matches(Some("ns")));
649        assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
650        assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
651        assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
652        // Unkeyed requests are whole-surface operations, so scoped grants must
653        // not widen to them.
654        assert!(!ResourcePattern::literal("ns").matches(None));
655        assert!(!ResourcePattern::prefix("agent-").matches(None));
656    }
657
658    #[test]
659    fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
660        let allow = |feature, action, resource| Grant {
661            effect: Effect::Allow,
662            feature,
663            action,
664            resource,
665        };
666        // Agent may read+write kv anywhere. The user it acts for may only read kv
667        // under `shared/`. The intersection permits only what BOTH allow.
668        let agent = vec![
669            allow(Feature::Kv, Action::Read, ResourcePattern::all()),
670            allow(Feature::Kv, Action::Write, ResourcePattern::all()),
671        ];
672        let user = vec![allow(
673            Feature::Kv,
674            Action::Read,
675            ResourcePattern::prefix("shared/"),
676        )];
677        assert!(delegated_allow(
678            &agent,
679            &user,
680            Feature::Kv,
681            Action::Read,
682            Some("shared/x")
683        ));
684        // Outside the user's prefix: agent alone would allow, the user does not.
685        assert!(!delegated_allow(
686            &agent,
687            &user,
688            Feature::Kv,
689            Action::Read,
690            Some("private/x")
691        ));
692        // The user cannot write at all, so the agent cannot write on its behalf.
693        assert!(!delegated_allow(
694            &agent,
695            &user,
696            Feature::Kv,
697            Action::Write,
698            Some("shared/x")
699        ));
700        // An empty grant set permits nothing.
701        assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
702    }
703
704    #[test]
705    fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
706        assert_eq!(
707            feature_action(AGDX_KV_GET_CODE),
708            Some((Feature::Kv, Action::Read))
709        );
710        assert_eq!(
711            feature_action(AGDX_KV_SET_CODE),
712            Some((Feature::Kv, Action::Write))
713        );
714        assert_eq!(
715            feature_action(AGDX_KV_DELETE_CODE),
716            Some((Feature::Kv, Action::Delete))
717        );
718        // Lease lifecycle is its own feature at the shared admin action, so a
719        // `kv:write` grant can never acquire, renew, or release.
720        assert_eq!(
721            feature_action(AGDX_KV_LEASE_CODE),
722            Some((Feature::KvLease, Action::Admin))
723        );
724        assert_eq!(
725            feature_action(AGDX_KV_LEASE_RENEW_CODE),
726            Some((Feature::KvLease, Action::Admin))
727        );
728        assert_eq!(
729            feature_action(AGDX_KV_RELEASE_CODE),
730            Some((Feature::KvLease, Action::Admin))
731        );
732        assert_eq!(
733            feature_action(AGDX_KV_CAS_FENCED_CODE),
734            Some((Feature::Kv, Action::Write))
735        );
736        assert_eq!(
737            feature_action(AGDX_REGISTER_SCHEMA_CODE),
738            Some((Feature::Projection, Action::Admin))
739        );
740        assert_eq!(
741            feature_action(AGDX_QUERY_CODE),
742            Some((Feature::Query, Action::Read))
743        );
744        assert_eq!(
745            feature_action(AGDX_GRAPH_UPSERT_CODE),
746            Some((Feature::Graph, Action::Write))
747        );
748        assert_eq!(
749            feature_action(AGDX_DESTINATION_GET_CODE),
750            Some((Feature::Destination, Action::Read))
751        );
752        assert_eq!(feature_action(AGDX_CHECKPOINT_CODE), None);
753        assert_eq!(feature_action(AGDX_DESTINATION_HTTP_CODE), None);
754        // No capability semantics: hello, batch, and the authz band self-gate.
755        assert_eq!(feature_action(AGDX_HELLO_CODE), None);
756        assert_eq!(feature_action(AGDX_BATCH_CODE), None);
757        assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
758        assert_eq!(feature_action(AGDX_CONTROL_PUBLISH_CODE), None);
759    }
760
761    #[test]
762    fn given_feature_action_pairs_when_indexed_then_should_fit_a_u128_mask() {
763        use strum::VariantArray;
764        let mut seen = std::collections::HashSet::new();
765        for &feature in Feature::VARIANTS {
766            for &action in Action::VARIANTS {
767                let index = action_index(feature, action);
768                assert!(index < 128, "index {index} must fit a u128 mask");
769                assert!(seen.insert(index), "index {index} collided");
770            }
771        }
772        assert_eq!(action_index(Feature::Destination, Action::Read), 40);
773        assert_eq!(action_index(Feature::Checkpoint, Action::Admin), 48);
774    }
775
776    #[test]
777    fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
778        // A grant naming a feature and action a newer peer added decodes to the
779        // Unrecognized catch-all rather than failing the whole grant set.
780        let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
781        let grant: Grant =
782            serde_json::from_str(json).expect("an unknown feature/action still decodes");
783        assert_eq!(grant.feature, Feature::Unrecognized);
784        assert_eq!(grant.action, Action::Unrecognized);
785        // An unrecognized capability matches no real request (requests are only
786        // ever classified into known features and actions by `feature_action`),
787        // so it is inert: default-deny, never a silent allow.
788        assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
789        // Displaying the deny sink must never panic (a panicking Display let one
790        // foreign grant crash a UI rendering a grant set) and the string parses
791        // back into the same sink.
792        assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
793        assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
794        assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
795        assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
796    }
797
798    #[test]
799    fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
800        let reply = AuthzReply::Whoami(WhoamiReply {
801            v: AUTHZ_OP_VERSION,
802            roles: vec!["admin".to_string()],
803            grants: vec![Grant {
804                effect: Effect::Allow,
805                feature: Feature::Kv,
806                action: Action::Write,
807                resource: ResourcePattern::all(),
808            }],
809        });
810        let bytes = encode_named(&reply).expect("reply serializes");
811        let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
812        assert_eq!(back, reply);
813    }
814}