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