Skip to main content

laser_wire/
authz.rs

1use crate::codes::*;
2use crate::error::InvalidError;
3use crate::limits::{MAX_AUTHZ_MUTATION_ID_BYTES, MAX_ROLE_NAME_BYTES};
4use serde::{Deserialize, Serialize};
5
6/// Whether a grant permits or forbids. `Deny` always wins over `Allow`.
7#[derive(
8    Clone,
9    Copy,
10    Debug,
11    Default,
12    PartialEq,
13    Eq,
14    Serialize,
15    Deserialize,
16    strum::Display,
17    strum::EnumString,
18    strum::VariantArray,
19)]
20#[strum(serialize_all = "snake_case")]
21#[serde(rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum Effect {
24    #[default]
25    Allow,
26    Deny,
27}
28
29/// The managed surface a grant applies to. Maps to the command bands, so a grant
30/// on `Kv` is orthogonal to one on `Projection`.
31#[derive(
32    Clone,
33    Copy,
34    Debug,
35    PartialEq,
36    Eq,
37    Hash,
38    Serialize,
39    Deserialize,
40    strum::Display,
41    strum::EnumString,
42    strum::VariantArray,
43)]
44#[strum(serialize_all = "snake_case")]
45#[serde(rename_all = "snake_case")]
46#[non_exhaustive]
47pub enum Feature {
48    Kv,
49    Memory,
50    Projection,
51    Fork,
52    Graph,
53    Query,
54    Agent,
55    Workflow,
56    /// Retired with lakehouse destinations. Kept so the action mask positions
57    /// of the features after it stay fixed.
58    Destination,
59    /// Retired with lakehouse destinations. Kept so the action mask positions
60    /// of the features after it stay fixed.
61    Checkpoint,
62    /// Administration of the authorization layer itself (defining roles and
63    /// binding them). Gated by `authz:admin`, never derived from a command code.
64    Authz,
65    /// Lease lifecycle in a coordination namespace: acquire, renew, and release
66    /// (`kv_lease:admin`), including delegated acquisition for another subject.
67    /// Split from [`Feature::Kv`] so a state writer's `kv:write` grant never
68    /// implies lease control. The action vocabulary stays the shared
69    /// read/write/delete/admin set (every action widens the coarse mask by one
70    /// bit per feature, so features split, actions do not grow).
71    KvLease,
72    /// Presenting a fence on a fenced compare-and-swap (`kv_fence:read`): the
73    /// right to validate against a coordination namespace's lease and fence
74    /// rows without any right to mutate them.
75    KvFence,
76    /// The saved consumer-filter catalog: browse (`read`), register, revise, and
77    /// describe (`write`), archive and drop (`delete`), and group bindings or
78    /// revision pause/resume (`admin`). Filtered reads themselves need only native source permission.
79    Filter,
80    /// Managed reads of one stream's agent sessions.
81    Session,
82    /// A feature name a newer peer used that this build does not know. An unknown
83    /// `feature` string decodes here instead of failing the whole grant set, and
84    /// it matches no request (requests only ever carry a known feature), so an
85    /// unrecognized capability is inert: default-deny, never a silent allow.
86    /// Displays as `unrecognized` (a Display that panicked would let one foreign
87    /// grant crash any UI that renders a grant set), and the string parses back
88    /// into this same deny sink.
89    #[serde(other)]
90    Unrecognized,
91}
92
93/// The prefix of a resource name scoped to one stream, `stream:<name>` or
94/// `stream:<name>/<local>`.
95pub const STREAM_RESOURCE_PREFIX: &str = "stream:";
96
97/// The resource name of `stream` itself.
98pub fn stream_resource(stream: &str) -> String {
99    format!("{STREAM_RESOURCE_PREFIX}{stream}")
100}
101
102/// The name of the managed resource `local` inside `stream`,
103/// `stream:<stream>/<local>`. A `local` that already starts with `stream:` is
104/// returned unchanged, so scoping is idempotent and a caller-scoped name is
105/// never scoped twice.
106pub fn scoped_resource(stream: &str, local: &str) -> String {
107    if local.starts_with(STREAM_RESOURCE_PREFIX) {
108        local.to_owned()
109    } else {
110        format!("{STREAM_RESOURCE_PREFIX}{stream}/{local}")
111    }
112}
113
114/// The stream and local part of a scoped resource name. `None` for a bare name,
115/// for the stream resource itself, and for a name with an empty stream or local
116/// part. Stream names never contain `/`, so the first `/` ends the stream.
117pub fn split_scoped_resource(name: &str) -> Option<(&str, &str)> {
118    let (stream, local) = name.strip_prefix(STREAM_RESOURCE_PREFIX)?.split_once('/')?;
119    (!stream.is_empty() && !local.is_empty()).then_some((stream, local))
120}
121
122/// How a deployment scopes its managed resources, as its replicated
123/// authorization state records it.
124#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum Tenancy {
127    /// One deployment-wide namespace. Every grant pattern is accepted.
128    #[default]
129    Deployment,
130    /// Each stream is an account. A role may allow access inside one stream
131    /// only, and the root role is bound to root alone.
132    Stream,
133}
134
135impl Tenancy {
136    pub fn is_deployment(&self) -> bool {
137        *self == Self::Deployment
138    }
139}
140
141/// Whether an allow grant reaches resources in more than one stream: a
142/// whole-feature grant, or a prefix that is not inside one `stream:<name>/`.
143/// A deny grant only narrows access, so it never spans.
144pub fn grant_spans_streams(grant: &Grant) -> bool {
145    if grant.effect == Effect::Deny {
146        return false;
147    }
148    match grant.resource.kind {
149        ResourceKind::Literal => false,
150        ResourceKind::Prefix => prefix_stream(&grant.resource.value).is_none(),
151        // A whole-feature grant, and any pattern kind this build does not know.
152        _ => true,
153    }
154}
155
156/// The one stream every allow grant of a role stays inside, which its audit
157/// events are attributed to. `None` when a grant reaches more than one stream
158/// or none, or when the role allows nothing.
159pub fn grants_stream(grants: &[Grant]) -> Option<String> {
160    let mut streams = grants
161        .iter()
162        .filter(|grant| grant.effect == Effect::Allow)
163        .map(|grant| match grant.resource.kind {
164            ResourceKind::Literal => split_scoped_resource(&grant.resource.value)
165                .map(|(stream, _)| stream)
166                .or_else(|| grant.resource.value.strip_prefix(STREAM_RESOURCE_PREFIX))
167                .filter(|stream| !stream.is_empty() && !stream.contains('/')),
168            ResourceKind::Prefix => prefix_stream(&grant.resource.value),
169            _ => None,
170        });
171    let first = streams.next()??;
172    streams
173        .all(|stream| stream == Some(first))
174        .then(|| first.to_owned())
175}
176
177/// The one stream a prefix stays inside, if any.
178fn prefix_stream(prefix: &str) -> Option<&str> {
179    if let Some((stream, _local)) = split_scoped_resource(prefix) {
180        return Some(stream);
181    }
182    prefix
183        .strip_prefix(STREAM_RESOURCE_PREFIX)?
184        .strip_suffix('/')
185        .filter(|stream| !stream.is_empty() && !stream.contains('/'))
186}
187
188/// The verb a grant permits, derived from the command code by [`feature_action`].
189#[derive(
190    Clone,
191    Copy,
192    Debug,
193    PartialEq,
194    Eq,
195    Hash,
196    Serialize,
197    Deserialize,
198    strum::Display,
199    strum::EnumString,
200    strum::VariantArray,
201)]
202#[strum(serialize_all = "snake_case")]
203#[serde(rename_all = "snake_case")]
204#[non_exhaustive]
205pub enum Action {
206    Read,
207    Write,
208    Delete,
209    Admin,
210    /// An action name a newer peer used that this build does not know. Decodes
211    /// here rather than failing the grant set, and matches no request, so an
212    /// unrecognized action is inert: default-deny. Displays as `unrecognized`
213    /// and parses back into this same deny sink, never a panic.
214    #[serde(other)]
215    Unrecognized,
216}
217
218/// How a [`ResourcePattern`] matches a request's resource selector.
219#[derive(
220    Clone,
221    Copy,
222    Debug,
223    Default,
224    PartialEq,
225    Eq,
226    Serialize,
227    Deserialize,
228    strum::Display,
229    strum::EnumString,
230    strum::VariantArray,
231)]
232#[strum(serialize_all = "snake_case")]
233#[serde(rename_all = "snake_case")]
234#[non_exhaustive]
235pub enum ResourceKind {
236    /// The whole feature, ignoring `value` (the absent-pattern default).
237    #[default]
238    All,
239    /// One exact resource name.
240    Literal,
241    /// Every resource name under a prefix.
242    Prefix,
243}
244
245/// A resource selector on a grant: literal, prefixed, or the whole feature.
246#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
247pub struct ResourcePattern {
248    #[serde(default)]
249    pub kind: ResourceKind,
250    #[serde(default, skip_serializing_if = "String::is_empty")]
251    pub value: String,
252}
253
254impl ResourcePattern {
255    /// The whole-feature pattern.
256    pub fn all() -> Self {
257        Self::default()
258    }
259
260    /// An exact-name pattern.
261    pub fn literal(value: impl Into<String>) -> Self {
262        Self {
263            kind: ResourceKind::Literal,
264            value: value.into(),
265        }
266    }
267
268    /// A prefix pattern (every name under `value`).
269    pub fn prefix(value: impl Into<String>) -> Self {
270        Self {
271            kind: ResourceKind::Prefix,
272            value: value.into(),
273        }
274    }
275
276    /// Whether `resource` (the selector decoded from a request) matches. An
277    /// unkeyed request (`None`) matches only a whole-feature pattern.
278    pub fn matches(&self, resource: Option<&str>) -> bool {
279        match (self.kind, resource) {
280            (ResourceKind::All, _) => true,
281            (ResourceKind::Literal, Some(r)) => r == self.value,
282            (ResourceKind::Prefix, Some(r)) => r.starts_with(&self.value),
283            (_, None) => false,
284        }
285    }
286}
287
288/// One capability grant: an effect on a `feature:action`, optionally scoped to a
289/// resource pattern.
290#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
291pub struct Grant {
292    pub effect: Effect,
293    pub feature: Feature,
294    pub action: Action,
295    #[serde(default)]
296    pub resource: ResourcePattern,
297}
298
299/// A named set of grants, bound to users. A user's effective capability is the
300/// union of the grants of every bound role, minus any matching deny.
301#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
302pub struct Role {
303    pub name: String,
304    pub grants: Vec<Grant>,
305}
306
307/// The roles bound to one user (by the server-stamped `user_id`).
308#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
309pub struct RoleBinding {
310    pub user_id: u32,
311    pub roles: Vec<String>,
312}
313
314/// The canonical role-name rule, shared by the SDK, the server, and the
315/// console so a name accepted by one tier is never rejected by the next. A
316/// valid name is non-empty, at most [`MAX_ROLE_NAME_BYTES`] bytes, and made
317/// only of ASCII letters, digits, `-`, `_`, and `.`. Enforced on define and
318/// bind, never on replay: a journaled role loads regardless, so tightening the
319/// rule cannot strand existing state.
320pub fn validate_role_name(name: &str) -> Result<(), InvalidError> {
321    crate::validate::validate_safelisted_name("role name", name, MAX_ROLE_NAME_BYTES)
322}
323
324/// The role definition rule: a valid name and grants only on features some
325/// request is authorized against. `memory`, `agent` and `workflow` have no
326/// command that checks them, and a feature this build does not know is never
327/// matched, so a grant on one would be stored and change nothing. Enforced on
328/// define, never on replay.
329pub fn validate_role(role: &Role) -> Result<(), InvalidError> {
330    validate_role_name(&role.name)?;
331    match role.grants.iter().find(|grant| {
332        matches!(
333            grant.feature,
334            Feature::Memory | Feature::Agent | Feature::Workflow | Feature::Unrecognized
335        )
336    }) {
337        Some(grant) => Err(InvalidError::new(format!(
338            "no request is authorized against the `{}` feature, so a grant on it has no effect",
339            grant.feature
340        ))),
341        None => Ok(()),
342    }
343}
344
345/// The canonical authorization mutation id rule: non-empty, at most
346/// [`MAX_AUTHZ_MUTATION_ID_BYTES`] bytes of ASCII letters, digits and `-`.
347pub fn validate_mutation_id(mutation_id: &str) -> Result<(), InvalidError> {
348    if mutation_id.is_empty() || mutation_id.len() > MAX_AUTHZ_MUTATION_ID_BYTES {
349        return Err(InvalidError::new(format!(
350            "mutation id must be 1 to {MAX_AUTHZ_MUTATION_ID_BYTES} bytes"
351        )));
352    }
353    if !mutation_id
354        .bytes()
355        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
356    {
357        return Err(InvalidError::new(
358            "mutation id may hold only ASCII letters, digits and '-'",
359        ));
360    }
361    Ok(())
362}
363
364/// The `(feature, action)` a managed command code authorizes against. `None` for
365/// a code with no capability semantics (hello, backend hello, client metadata,
366/// batch, and the authz band itself), which is gated another way.
367pub fn feature_action(code: u32) -> Option<(Feature, Action)> {
368    let pair = match code {
369        AGDX_QUERY_CODE => (Feature::Query, Action::Read),
370        AGDX_GET_PROJECTION_CODE
371        | AGDX_LIST_PROJECTIONS_CODE
372        | AGDX_GET_SCHEMA_CODE
373        | AGDX_LIST_SCHEMAS_CODE
374        | AGDX_DECODE_RECORD_CODE => (Feature::Projection, Action::Read),
375        AGDX_REGISTER_SCHEMA_CODE => (Feature::Projection, Action::Admin),
376        AGDX_KV_GET_CODE | AGDX_KV_SCAN_CODE | AGDX_KV_NAMESPACES_CODE | AGDX_KV_EXISTS_CODE => {
377            (Feature::Kv, Action::Read)
378        }
379        // A fenced CAS additionally requires `kv_fence:read` on its
380        // coordination namespace ([`crate::kv::KvCasFenced::fence_namespace`]).
381        // This map carries the primary target-namespace grant and the enforcer
382        // checks the fence grant beside it.
383        AGDX_KV_SET_CODE
384        | AGDX_KV_CAS_CODE
385        | AGDX_KV_CAS_FENCED_CODE
386        | AGDX_KV_PATCH_CODE
387        | AGDX_KV_EXPIRE_CODE
388        | AGDX_KV_COPY_CODE
389        | AGDX_KV_MOVE_CODE => (Feature::Kv, Action::Write),
390        // Lease lifecycle is its own feature: a `kv:write` grant on a
391        // namespace must never imply acquiring, renewing, or releasing the
392        // lease that fences it. `admin` is the house action for a surface's
393        // privileged op (`projection:admin`, `fork:admin`), so lease control
394        // is `kv_lease:admin`, distinct from `kv:admin` by the feature alone.
395        AGDX_KV_LEASE_CODE | AGDX_KV_LEASE_RENEW_CODE | AGDX_KV_RELEASE_CODE => {
396            (Feature::KvLease, Action::Admin)
397        }
398        AGDX_KV_DELETE_CODE | AGDX_KV_DELETE_MANY_CODE => (Feature::Kv, Action::Delete),
399        AGDX_FORK_LIST_CODE => (Feature::Fork, Action::Read),
400        AGDX_FORK_CREATE_CODE | AGDX_FORK_PUT_CODE => (Feature::Fork, Action::Write),
401        AGDX_FORK_PROMOTE_CODE => (Feature::Fork, Action::Admin),
402        AGDX_FORK_DELETE_CODE => (Feature::Fork, Action::Delete),
403        AGDX_GRAPH_QUERY_CODE | AGDX_GRAPH_NEIGHBORS_CODE => (Feature::Graph, Action::Read),
404        AGDX_GRAPH_UPSERT_CODE => (Feature::Graph, Action::Write),
405        AGDX_SESSION_GET_CODE
406        | AGDX_SESSION_LIST_CODE
407        | AGDX_SESSION_EVENTS_CODE
408        | AGDX_SESSION_STATE_CODE
409        | AGDX_SESSION_LINKS_CODE
410        | AGDX_SESSION_SOURCES_CODE
411        | AGDX_SESSION_CHANGES_CODE => (Feature::Session, Action::Read),
412        AGDX_GET_FILTER_CODE
413        | AGDX_LIST_FILTERS_CODE
414        | AGDX_LIST_FILTER_REVISIONS_CODE
415        | AGDX_GET_FILTER_BINDING_CODE
416        | AGDX_LIST_FILTER_BINDINGS_CODE
417        | AGDX_FILTER_OPERATION_CODE => (Feature::Filter, Action::Read),
418        _ => return None,
419    };
420    Some(pair)
421}
422
423/// The number of [`Action`] variants (including the `Unrecognized` catch-all):
424/// the stride of the shared coarse-capability bitmask layout ([`action_index`]).
425/// The stride must cover every action so one feature's last action bit never
426/// collides with the next feature's first. Keep the action set minimal: every
427/// added action costs one mask bit per feature, so new capability semantics
428/// split into a feature (a fresh row) rather than an action (a wider stride).
429pub const ACTION_COUNT: usize = 5;
430
431/// The bit index of a `(feature, action)` in the coarse-capability bitmask, a
432/// pure function shared by every enforcer so the streaming server and the managed backend cannot drift.
433/// `Feature`/`Action` are `VariantArray` enums, so the ordinal is stable per wire
434/// revision.
435pub fn action_index(feature: Feature, action: Action) -> usize {
436    feature as usize * ACTION_COUNT + action as usize
437}
438
439// The coarse-capability bitmask every enforcer shares is a `u128` (widened
440// from `u64` when the kv_lease/kv_fence feature split pushed the grid past 64),
441// so every `(feature, action)` bit index must fit in 128 bits. Adding features
442// or actions past that ceiling is a compile error here, not a silent runtime
443// aliasing of two distinct capabilities onto one bit. `ACTION_COUNT` must also
444// stay the true `Action` variant count, or `action_index`'s stride would skip
445// or overlap rows.
446const _: () = {
447    assert!(
448        <Action as strum::VariantArray>::VARIANTS.len() == ACTION_COUNT,
449        "ACTION_COUNT must equal the number of Action variants"
450    );
451    assert!(
452        <Feature as strum::VariantArray>::VARIANTS.len() * ACTION_COUNT <= 128,
453        "authz coarse-capability bitmask overflow: Feature count * ACTION_COUNT exceeds 128 bits"
454    );
455};
456
457/// Whether `grants` permit `(feature, action)` on `resource`, deny-wins. An
458/// empty set permits nothing (there is no allow to match). `resource` is the
459/// selector decoded from a request, or `None` for an unkeyed op.
460pub fn grants_allow(
461    grants: &[Grant],
462    feature: Feature,
463    action: Action,
464    resource: Option<&str>,
465) -> bool {
466    let mut allowed = false;
467    for grant in grants {
468        if grant.feature == feature && grant.action == action && grant.resource.matches(resource) {
469            match grant.effect {
470                Effect::Deny => return false,
471                Effect::Allow => allowed = true,
472            }
473        }
474    }
475    allowed
476}
477
478/// The on-behalf-of check: an agent acting for a user is permitted an op only
479/// when both its own grants and the invoking user's grants permit it. The agent
480/// can never exceed the user who invoked it (permission intersection).
481pub fn delegated_allow(
482    agent: &[Grant],
483    user: &[Grant],
484    feature: Feature,
485    action: Action,
486    resource: Option<&str>,
487) -> bool {
488    grants_allow(agent, feature, action, resource) && grants_allow(user, feature, action, resource)
489}
490
491/// Request the caller's own effective capabilities.
492#[derive(Clone, Debug, Serialize, Deserialize)]
493pub struct WhoamiReq {
494    pub v: u32,
495}
496
497/// The caller's bound roles and their flattened grants.
498#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
499pub struct WhoamiReply {
500    pub v: u32,
501    pub roles: Vec<String>,
502    pub grants: Vec<Grant>,
503}
504
505/// Request to list roles, optionally filtered. Absent filters list every role,
506/// the same bounded-registry browse as `ListProjections`.
507#[derive(Clone, Debug, Serialize, Deserialize)]
508pub struct ListRolesReq {
509    pub v: u32,
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub name_prefix: Option<String>,
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub search: Option<String>,
514}
515
516/// Every matching role with its full grant set.
517#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
518pub struct ListRolesReply {
519    pub v: u32,
520    pub roles: Vec<Role>,
521}
522
523/// Request one role by name.
524#[derive(Clone, Debug, Serialize, Deserialize)]
525pub struct GetRoleReq {
526    pub v: u32,
527    pub name: String,
528}
529
530/// Request one user's bound role names.
531#[derive(Clone, Debug, Serialize, Deserialize)]
532pub struct GetBindingsReq {
533    pub v: u32,
534    pub user_id: u32,
535}
536
537/// One user's bound role names and the binding revision they were read at.
538#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
539pub struct BindingsReply {
540    pub v: u32,
541    pub roles: Vec<String>,
542    /// The revision of the binding the roles were read from, the value a
543    /// [`BindRolesReq::expect_revision`] names to replace exactly this binding.
544    /// Zero for a user that was never bound.
545    pub revision: u64,
546}
547
548/// Define or replace a role (upsert, carries the full grant set).
549#[derive(Clone, Debug, Serialize, Deserialize)]
550pub struct DefineRoleReq {
551    pub v: u32,
552    pub role: Role,
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub mutation_id: Option<String>,
555}
556
557/// Delete a role by name.
558#[derive(Clone, Debug, Serialize, Deserialize)]
559pub struct DeleteRoleReq {
560    pub v: u32,
561    pub name: String,
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub mutation_id: Option<String>,
564}
565
566/// Bind roles to a user (replace the user's whole role set).
567#[derive(Clone, Debug, Serialize, Deserialize)]
568pub struct BindRolesReq {
569    pub v: u32,
570    pub user_id: u32,
571    pub roles: Vec<String>,
572    /// Compare-and-swap precondition: apply only if the binding's current
573    /// revision equals this. Absent means an unconditional replace (the prior
574    /// behavior), so a caller opts into optimistic concurrency. A mismatch fails
575    /// with [`AuthzError::Conflict`]. Skip-none, so a request without it stays
576    /// byte-identical.
577    #[serde(default, skip_serializing_if = "Option::is_none")]
578    pub expect_revision: Option<u64>,
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub mutation_id: Option<String>,
581}
582
583/// Which authorization subject an [`AuthzHistoryReq`] reads the change log of.
584#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
585#[serde(rename_all = "snake_case")]
586pub enum AuthzSubject {
587    /// One role by name.
588    Role(String),
589    /// One user's bindings.
590    Binding { user_id: u32 },
591    /// Every authorization change.
592    All,
593    /// Every change attributed to one stream: roles whose grants stay inside
594    /// it and bindings of such roles. What a stream's owner may audit.
595    Stream(String),
596}
597
598/// Read the authorization change history for a subject, paged by revision. The
599/// audit surface the first compliance conversation opens: who granted what, when.
600#[derive(Clone, Debug, Serialize, Deserialize)]
601pub struct AuthzHistoryReq {
602    pub v: u32,
603    pub subject: AuthzSubject,
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub after_revision: Option<u64>,
606    pub limit: u32,
607}
608
609/// What an [`AuthzEvent`] recorded.
610#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
611#[serde(rename_all = "snake_case")]
612pub enum AuthzEventKind {
613    /// A role was defined or replaced.
614    RoleDefined(String),
615    /// A role was deleted.
616    RoleDeleted(String),
617    /// A user's role set was rebound.
618    RolesBound { user_id: u32, roles: Vec<String> },
619    /// The deployment's tenancy was set.
620    TenancySet(Tenancy),
621}
622
623/// One recorded authorization change: its revision, who made it, when, and what.
624#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
625pub struct AuthzEvent {
626    pub revision: u64,
627    pub actor: String,
628    pub at_micros: u64,
629    pub op: AuthzEventKind,
630    /// The stream the change is attributed to, see [`grants_stream`]. Absent
631    /// for a change that concerns no single stream.
632    #[serde(default, skip_serializing_if = "Option::is_none")]
633    pub stream: Option<String>,
634}
635
636/// A page of authorization change history.
637#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
638pub struct AuthzHistoryReply {
639    pub v: u32,
640    pub events: Vec<AuthzEvent>,
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub next_after_revision: Option<u64>,
643}
644
645/// Reply to any authorization command, shaped per request.
646#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
647#[non_exhaustive]
648pub enum AuthzReply {
649    /// A mutating command applied (`define_role`, `delete_role`, `bind_roles`).
650    Ok,
651    /// `whoami`: the caller's effective capabilities.
652    Whoami(WhoamiReply),
653    /// `list_roles`: every matching role.
654    Roles(ListRolesReply),
655    /// `get_role`: the role with the requested name, or `None`.
656    Role(Option<Role>),
657    /// `get_bindings`: one user's bound role names.
658    Bindings(BindingsReply),
659    /// `history`: a page of the authorization change log.
660    History(AuthzHistoryReply),
661    Err(AuthzError),
662}
663
664/// An authorization command failure.
665#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
666#[non_exhaustive]
667pub enum AuthzError {
668    #[error("authz not supported: {0}")]
669    Unsupported(String),
670    #[error("unauthorized")]
671    Unauthorized,
672    #[error("unknown role: {0}")]
673    UnknownRole(String),
674    /// A define or bind named a role that fails [`validate_role_name`].
675    #[error("invalid role name: {0}")]
676    InvalidName(String),
677    /// A define granted a feature no request is authorized against, see
678    /// [`validate_role`]. Nothing applied.
679    #[error("invalid grant: {0}")]
680    InvalidGrant(String),
681    /// A compare-and-swap bind lost the precondition: the binding's current
682    /// revision is not the one the request expected, so a concurrent admin got
683    /// there first. The caller re-reads and retries.
684    #[error("revision conflict: current is {current_revision}")]
685    Conflict { current_revision: u64 },
686    #[error("unsupported authz op version (expected {expected}, got {got})")]
687    Version { expected: u32, got: u32 },
688    /// A grant refused because it reaches past one stream while the server
689    /// runs with stream tenancy, for example a wildcard or a prefix spanning
690    /// streams.
691    #[error("refused by stream tenancy: {0}")]
692    TenancyViolation(String),
693    /// The mutation id already names a different request of the same account.
694    /// Nothing applied. A new operation needs a new mutation id.
695    #[error("mutation id conflict: {0}")]
696    MutationConflict(String),
697    /// No receipt capacity is free for a new mutation id inside the replay
698    /// window. Nothing applied. Retrying the same request later may succeed.
699    #[error("authorization receipt capacity exhausted: {0}")]
700    ResourceLimit(String),
701}
702
703#[cfg(all(test, feature = "cbor"))]
704mod tests {
705    use super::*;
706
707    #[test]
708    fn given_grants_on_unenforced_features_when_a_role_is_validated_then_should_refuse_them() {
709        let grant = |feature| Grant {
710            effect: Effect::Allow,
711            feature,
712            action: Action::Read,
713            resource: ResourcePattern::all(),
714        };
715        let role = |grants| Role {
716            name: "reader".to_owned(),
717            grants,
718        };
719        for feature in [
720            Feature::Memory,
721            Feature::Agent,
722            Feature::Workflow,
723            Feature::Unrecognized,
724        ] {
725            assert!(
726                validate_role(&role(vec![grant(Feature::Kv), grant(feature)])).is_err(),
727                "{feature}"
728            );
729        }
730        validate_role(&role(vec![grant(Feature::Kv), grant(Feature::Session)]))
731            .expect("enforced features validate");
732        assert!(
733            validate_role(&Role {
734                name: "bad name".to_owned(),
735                grants: Vec::new(),
736            })
737            .is_err()
738        );
739    }
740
741    #[test]
742    fn given_role_grants_when_attributed_then_should_name_one_stream_or_none() {
743        let allow = |kind: ResourceKind, value: &str| Grant {
744            effect: Effect::Allow,
745            feature: Feature::Kv,
746            action: Action::Read,
747            resource: ResourcePattern {
748                kind,
749                value: value.to_owned(),
750            },
751        };
752        assert_eq!(
753            grants_stream(&[
754                allow(ResourceKind::Prefix, "stream:acme/"),
755                allow(ResourceKind::Literal, "stream:acme/cart"),
756            ]),
757            Some("acme".to_owned())
758        );
759        assert_eq!(
760            grants_stream(&[
761                allow(ResourceKind::Prefix, "stream:acme/"),
762                allow(ResourceKind::Literal, "stream:other/cart"),
763            ]),
764            None
765        );
766        assert_eq!(grants_stream(&[allow(ResourceKind::All, "")]), None);
767        assert_eq!(
768            grants_stream(&[allow(ResourceKind::Prefix, "stream:acme")]),
769            None
770        );
771        assert_eq!(grants_stream(&[]), None);
772    }
773
774    #[test]
775    fn given_a_tenancy_event_when_round_tripped_then_should_keep_it_and_spanning_should_follow_the_pattern()
776     {
777        let event = AuthzEventKind::TenancySet(Tenancy::Stream);
778        let bytes = crate::framing::encode_named(&event).expect("encodes");
779        assert_eq!(
780            crate::framing::decode_named::<AuthzEventKind>(&bytes).expect("decodes"),
781            event
782        );
783        let allow = |resource| Grant {
784            effect: Effect::Allow,
785            feature: Feature::Kv,
786            action: Action::Read,
787            resource,
788        };
789        assert!(grant_spans_streams(&allow(ResourcePattern::all())));
790        assert!(grant_spans_streams(&allow(ResourcePattern::prefix(
791            "orders"
792        ))));
793        assert!(!grant_spans_streams(&allow(ResourcePattern::prefix(
794            "stream:a/"
795        ))));
796        assert!(!grant_spans_streams(&allow(ResourcePattern::literal("x"))));
797        for spanning in [
798            ResourcePattern::prefix("stream:"),
799            ResourcePattern::prefix("stream:acme"),
800            ResourcePattern::prefix("stream:/notes"),
801            ResourcePattern::prefix("acme/"),
802            ResourcePattern::prefix(""),
803        ] {
804            assert!(
805                grant_spans_streams(&allow(spanning.clone())),
806                "{spanning:?} reaches more than one stream"
807            );
808        }
809        for inside in [
810            ResourcePattern::literal("stream:acme"),
811            ResourcePattern::literal("stream:acme/notes"),
812            ResourcePattern::prefix("stream:acme/agent."),
813        ] {
814            assert!(
815                !grant_spans_streams(&allow(inside.clone())),
816                "{inside:?} stays inside one stream"
817            );
818        }
819        assert!(!grant_spans_streams(&Grant {
820            effect: Effect::Deny,
821            ..allow(ResourcePattern::all())
822        }));
823        assert!(Tenancy::default().is_deployment());
824    }
825    use crate::framing::{decode_named, encode_named};
826
827    #[test]
828    fn given_a_tenancy_violation_when_round_tripped_then_should_keep_the_reason() {
829        let reply = AuthzReply::Err(AuthzError::TenancyViolation("prefix acme".to_owned()));
830        let back: AuthzReply =
831            decode_named(&encode_named(&reply).expect("reply serializes")).expect("deserializes");
832        assert!(matches!(
833            back,
834            AuthzReply::Err(AuthzError::TenancyViolation(reason)) if reason == "prefix acme"
835        ));
836    }
837
838    #[test]
839    fn given_a_local_name_when_scoped_then_should_prefix_the_stream() {
840        assert_eq!(
841            scoped_resource("acme", "agent.keys"),
842            "stream:acme/agent.keys"
843        );
844        assert_eq!(scoped_resource("acme", "a/b"), "stream:acme/a/b");
845    }
846
847    #[test]
848    fn given_a_scoped_name_when_scoped_again_then_should_return_it_unchanged() {
849        let scoped = scoped_resource("acme", "sessions");
850        assert_eq!(scoped_resource("acme", &scoped), scoped);
851        assert_eq!(
852            scoped_resource("other", "stream:acme/sessions"),
853            "stream:acme/sessions"
854        );
855    }
856
857    #[test]
858    fn given_scoped_names_when_split_then_should_return_stream_and_local_part() {
859        assert_eq!(
860            split_scoped_resource("stream:acme/agent.keys"),
861            Some(("acme", "agent.keys"))
862        );
863        assert_eq!(
864            split_scoped_resource("stream:acme/a/b"),
865            Some(("acme", "a/b"))
866        );
867        assert_eq!(
868            split_scoped_resource(&scoped_resource("acme", "graph")),
869            Some(("acme", "graph"))
870        );
871    }
872
873    #[test]
874    fn given_unscoped_names_when_split_then_should_return_none() {
875        assert_eq!(split_scoped_resource("agent.keys"), None);
876        assert_eq!(split_scoped_resource("stream:acme"), None);
877        assert_eq!(split_scoped_resource("stream:/local"), None);
878        assert_eq!(split_scoped_resource("stream:acme/"), None);
879        assert_eq!(split_scoped_resource("streams:acme/local"), None);
880    }
881
882    #[test]
883    fn given_a_role_when_round_tripped_then_should_preserve_grants() {
884        let role = Role {
885            name: "kv-reader".to_string(),
886            grants: vec![
887                Grant {
888                    effect: Effect::Allow,
889                    feature: Feature::Kv,
890                    action: Action::Read,
891                    resource: ResourcePattern::prefix("agent-abc/"),
892                },
893                Grant {
894                    effect: Effect::Deny,
895                    feature: Feature::Kv,
896                    action: Action::Read,
897                    resource: ResourcePattern::literal("agent-abc/secret"),
898                },
899            ],
900        };
901        let bytes = encode_named(&role).expect("role serializes");
902        let back: Role = decode_named(&bytes).expect("role deserializes");
903        assert_eq!(back, role);
904    }
905
906    #[test]
907    fn given_role_names_when_validated_then_should_enforce_charset_and_length() {
908        assert!(validate_role_name("kv-reader").is_ok());
909        assert!(validate_role_name("ops.admin_2").is_ok());
910        assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES)).is_ok());
911        assert!(validate_role_name("").is_err(), "empty");
912        assert!(validate_role_name("bad name").is_err(), "space");
913        assert!(validate_role_name("rĂ´le").is_err(), "non-ascii");
914        assert!(validate_role_name(&"r".repeat(MAX_ROLE_NAME_BYTES + 1)).is_err());
915    }
916
917    #[test]
918    fn given_a_resource_pattern_when_matched_then_should_honor_its_kind() {
919        assert!(ResourcePattern::all().matches(Some("anything")));
920        assert!(ResourcePattern::all().matches(None));
921        assert!(ResourcePattern::literal("ns").matches(Some("ns")));
922        assert!(!ResourcePattern::literal("ns").matches(Some("ns2")));
923        assert!(ResourcePattern::prefix("agent-").matches(Some("agent-abc")));
924        assert!(!ResourcePattern::prefix("agent-").matches(Some("other")));
925        // Unkeyed requests are whole-surface operations, so scoped grants must
926        // not widen to them.
927        assert!(!ResourcePattern::literal("ns").matches(None));
928        assert!(!ResourcePattern::prefix("agent-").matches(None));
929    }
930
931    #[test]
932    fn given_delegation_when_checked_then_agent_is_intersected_with_the_user() {
933        let allow = |feature, action, resource| Grant {
934            effect: Effect::Allow,
935            feature,
936            action,
937            resource,
938        };
939        // Agent may read+write kv anywhere. The user it acts for may only read kv
940        // under `shared/`. The intersection permits only what BOTH allow.
941        let agent = vec![
942            allow(Feature::Kv, Action::Read, ResourcePattern::all()),
943            allow(Feature::Kv, Action::Write, ResourcePattern::all()),
944        ];
945        let user = vec![allow(
946            Feature::Kv,
947            Action::Read,
948            ResourcePattern::prefix("shared/"),
949        )];
950        assert!(delegated_allow(
951            &agent,
952            &user,
953            Feature::Kv,
954            Action::Read,
955            Some("shared/x")
956        ));
957        // Outside the user's prefix: agent alone would allow, the user does not.
958        assert!(!delegated_allow(
959            &agent,
960            &user,
961            Feature::Kv,
962            Action::Read,
963            Some("private/x")
964        ));
965        // The user cannot write at all, so the agent cannot write on its behalf.
966        assert!(!delegated_allow(
967            &agent,
968            &user,
969            Feature::Kv,
970            Action::Write,
971            Some("shared/x")
972        ));
973        // An empty grant set permits nothing.
974        assert!(!grants_allow(&[], Feature::Kv, Action::Read, None));
975    }
976
977    #[test]
978    fn given_a_command_code_when_classified_then_should_map_to_feature_and_action() {
979        assert_eq!(
980            feature_action(AGDX_KV_GET_CODE),
981            Some((Feature::Kv, Action::Read))
982        );
983        assert_eq!(
984            feature_action(AGDX_KV_SET_CODE),
985            Some((Feature::Kv, Action::Write))
986        );
987        assert_eq!(
988            feature_action(AGDX_KV_DELETE_CODE),
989            Some((Feature::Kv, Action::Delete))
990        );
991        // Lease lifecycle is its own feature at the shared admin action, so a
992        // `kv:write` grant can never acquire, renew, or release.
993        assert_eq!(
994            feature_action(AGDX_KV_LEASE_CODE),
995            Some((Feature::KvLease, Action::Admin))
996        );
997        assert_eq!(
998            feature_action(AGDX_KV_LEASE_RENEW_CODE),
999            Some((Feature::KvLease, Action::Admin))
1000        );
1001        assert_eq!(
1002            feature_action(AGDX_KV_RELEASE_CODE),
1003            Some((Feature::KvLease, Action::Admin))
1004        );
1005        assert_eq!(
1006            feature_action(AGDX_KV_CAS_FENCED_CODE),
1007            Some((Feature::Kv, Action::Write))
1008        );
1009        assert_eq!(
1010            feature_action(AGDX_REGISTER_SCHEMA_CODE),
1011            Some((Feature::Projection, Action::Admin))
1012        );
1013        assert_eq!(
1014            feature_action(AGDX_QUERY_CODE),
1015            Some((Feature::Query, Action::Read))
1016        );
1017        assert_eq!(
1018            feature_action(AGDX_GRAPH_UPSERT_CODE),
1019            Some((Feature::Graph, Action::Write))
1020        );
1021        // No capability semantics: hello, batch, and the authz band self-gate.
1022        assert_eq!(feature_action(AGDX_HELLO_CODE), None);
1023        assert_eq!(feature_action(AGDX_BATCH_CODE), None);
1024        assert_eq!(feature_action(AGDX_AUTHZ_WHOAMI_CODE), None);
1025        assert_eq!(feature_action(AGDX_CONTROL_PUBLISH_CODE), None);
1026    }
1027
1028    #[test]
1029    fn given_feature_action_pairs_when_indexed_then_should_fit_a_u128_mask() {
1030        use strum::VariantArray;
1031        let mut seen = std::collections::HashSet::new();
1032        for &feature in Feature::VARIANTS {
1033            for &action in Action::VARIANTS {
1034                let index = action_index(feature, action);
1035                assert!(index < 128, "index {index} must fit a u128 mask");
1036                assert!(seen.insert(index), "index {index} collided");
1037            }
1038        }
1039        assert_eq!(action_index(Feature::Destination, Action::Read), 40);
1040        assert_eq!(action_index(Feature::Checkpoint, Action::Admin), 48);
1041        assert_eq!(action_index(Feature::Session, Action::Read), 70);
1042    }
1043
1044    #[test]
1045    fn given_an_unknown_feature_or_action_when_decoded_then_should_be_unrecognized_and_deny() {
1046        // A grant naming a feature and action a newer peer added decodes to the
1047        // Unrecognized catch-all rather than failing the whole grant set.
1048        let json = r#"{"effect":"allow","feature":"quantum","action":"teleport","resource":{"kind":"all"}}"#;
1049        let grant: Grant =
1050            serde_json::from_str(json).expect("an unknown feature/action still decodes");
1051        assert_eq!(grant.feature, Feature::Unrecognized);
1052        assert_eq!(grant.action, Action::Unrecognized);
1053        // An unrecognized capability matches no real request (requests are only
1054        // ever classified into known features and actions by `feature_action`),
1055        // so it is inert: default-deny, never a silent allow.
1056        assert!(!grants_allow(&[grant], Feature::Kv, Action::Read, None));
1057        // Displaying the deny sink must never panic (a panicking Display let one
1058        // foreign grant crash a UI rendering a grant set) and the string parses
1059        // back into the same sink.
1060        assert_eq!(Feature::Unrecognized.to_string(), "unrecognized");
1061        assert_eq!(Action::Unrecognized.to_string(), "unrecognized");
1062        assert_eq!("unrecognized".parse(), Ok(Feature::Unrecognized));
1063        assert_eq!("unrecognized".parse(), Ok(Action::Unrecognized));
1064    }
1065
1066    #[test]
1067    fn given_an_authz_reply_when_round_tripped_then_should_preserve_the_variant() {
1068        let reply = AuthzReply::Whoami(WhoamiReply {
1069            v: AUTHZ_OP_VERSION,
1070            roles: vec!["admin".to_string()],
1071            grants: vec![Grant {
1072                effect: Effect::Allow,
1073                feature: Feature::Kv,
1074                action: Action::Write,
1075                resource: ResourcePattern::all(),
1076            }],
1077        });
1078        let bytes = encode_named(&reply).expect("reply serializes");
1079        let back: AuthzReply = decode_named(&bytes).expect("reply deserializes");
1080        assert_eq!(back, reply);
1081    }
1082}