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