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