axon/store/capability.rs
1//! §Fase 35.j (v1.30.0) — Pillar IV: capability-typed store access.
2//!
3//! An `axonstore` may declare a `capability:` slug. Access to that
4//! store — `retrieve` / `persist` / `mutate` / `purge` — then requires
5//! the caller to hold that capability. Data isolation stops being an
6//! app-code `if tenant_id == …` the developer must remember; it
7//! becomes a **language guarantee**.
8//!
9//! # Two enforcement layers (D11)
10//!
11//! 1. **Compile-time** (`axon-frontend` type-checker): an
12//! `axonendpoint` executing a flow that accesses a capability-gated
13//! store must GRANT that capability in its `requires:` list. A
14//! program that would let an under-privileged endpoint reach a
15//! gated store does not type-check.
16//!
17//! 2. **Runtime re-check** (this module): the streaming dispatcher's
18//! store handlers re-verify, against the capabilities the request
19//! actually carries, that the gated store may be touched —
20//! defense-in-depth behind the static guarantee.
21//!
22//! # OSS / ENTERPRISE seam (§6 — 35.j is SPLIT)
23//!
24//! This module + the type-checker enforcement are the **OSS
25//! mechanism** — a capability is a slug, the check is set membership.
26//! The **enterprise** layer owns the multitenant *operations*:
27//! per-tenant capability provisioning, tenant-scoped connection
28//! routing, per-tenant audit-chain segregation. The seam is the
29//! `held` capability set: this module checks it; enterprise tooling
30//! provisions it per tenant.
31//!
32//! Pure + total — no I/O.
33
34use std::fmt;
35
36/// A store access denied for lack of the required capability. Carries
37/// the full picture — the store, the capability it demands, and what
38/// the caller actually holds — so the denial is auditable without
39/// server-log diving.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct CapabilityDenied {
42 /// The store whose access was denied.
43 pub store: String,
44 /// The capability slug the store requires.
45 pub required: String,
46 /// The capabilities the caller actually holds.
47 pub held: Vec<String>,
48}
49
50impl fmt::Display for CapabilityDenied {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 write!(
53 f,
54 "access to axonstore `{}` denied: it requires capability \
55 `{}`, which the caller does not hold (held: {:?})",
56 self.store, self.required, self.held
57 )
58 }
59}
60
61impl std::error::Error for CapabilityDenied {}
62
63/// Check whether a caller holding `held` capabilities may access the
64/// store `store_name`, which is gated by `required`.
65///
66/// - `required` empty — the store declares no capability gate → `Ok`.
67/// - `required` ∈ `held` — the caller holds it → `Ok`.
68/// - otherwise → `Err(CapabilityDenied)`.
69///
70/// Total: every input maps to exactly one outcome.
71pub fn check_store_capability(
72 store_name: &str,
73 required: &str,
74 held: &[String],
75) -> Result<(), CapabilityDenied> {
76 if required.is_empty() || held.iter().any(|h| h == required) {
77 Ok(())
78 } else {
79 Err(CapabilityDenied {
80 store: store_name.to_string(),
81 required: required.to_string(),
82 held: held.to_vec(),
83 })
84 }
85}
86
87// ════════════════════════════════════════════════════════════════════
88// Unit tests
89// ════════════════════════════════════════════════════════════════════
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 fn slugs(items: &[&str]) -> Vec<String> {
96 items.iter().map(|s| s.to_string()).collect()
97 }
98
99 #[test]
100 fn ungated_store_is_always_allowed() {
101 // Empty `required` — no capability declared on the store.
102 assert!(check_store_capability("cache", "", &[]).is_ok());
103 assert!(check_store_capability("cache", "", &slugs(&["x"])).is_ok());
104 }
105
106 #[test]
107 fn held_capability_allows_access() {
108 let held = slugs(&["tenant.read", "audit.write"]);
109 assert!(check_store_capability("tenants", "tenant.read", &held).is_ok());
110 }
111
112 #[test]
113 fn missing_capability_is_denied() {
114 let held = slugs(&["audit.write"]);
115 match check_store_capability("tenants", "tenant.read", &held) {
116 Err(denied) => {
117 assert_eq!(denied.store, "tenants");
118 assert_eq!(denied.required, "tenant.read");
119 assert_eq!(denied.held, held);
120 }
121 Ok(()) => panic!("expected a capability denial"),
122 }
123 }
124
125 #[test]
126 fn empty_held_set_denies_a_gated_store() {
127 assert!(check_store_capability("tenants", "tenant.read", &[]).is_err());
128 }
129
130 #[test]
131 fn capability_match_is_exact_not_prefix() {
132 // `tenant.read` must not satisfy a `tenant` requirement, nor
133 // vice versa — capability slugs are matched whole.
134 let held = slugs(&["tenant"]);
135 assert!(check_store_capability("s", "tenant.read", &held).is_err());
136 let held2 = slugs(&["tenant.read"]);
137 assert!(check_store_capability("s", "tenant", &held2).is_err());
138 }
139
140 #[test]
141 fn capability_denied_display_is_informative() {
142 let denied = CapabilityDenied {
143 store: "tenants".into(),
144 required: "tenant.read".into(),
145 held: slugs(&["audit.write"]),
146 };
147 let msg = denied.to_string();
148 assert!(msg.contains("tenants"));
149 assert!(msg.contains("tenant.read"));
150 assert!(!msg.is_empty());
151 }
152}