Skip to main content

basil_core/core/
state.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Shared broker state: a hot-swappable policy **generation** + the backend
6//! manager, constructed once at startup and shared (`Arc`) across connections.
7//!
8//! [`BrokerState`] is the single object the gRPC service adapters share. It
9//! bundles:
10//!
11//! - the active [`Generation`]: the loaded **catalog**, **resolved policy**
12//!   index, and export-resolved **config** tables that are the inputs to the
13//!   [`Pdp`] (`vault-1l8`), plus a monotonic generation **id**, held behind an
14//!   [`arc_swap::ArcSwap`] so a future reload (`basil-y3e.2`) can atomically swap
15//!   in a fresh generation without restarting the broker, and
16//! - the [`BackendManager`] (`vault-jso`) that routes an allowed op to its backend.
17//!
18//! **Generation pinning.** The catalog/policy/config triple is no longer
19//! immutable for the broker's lifetime: a SIGHUP reload may replace it atomically.
20//! To keep each operation coherent (never deciding with an old catalog but a new
21//! policy), every RPC snapshots the generation **once** at request entry via
22//! [`BrokerState::load_generation`] and uses that one [`Generation`] (its
23//! [`Generation::pdp`], [`Generation::config`], and [`Generation::id`]) for the
24//! whole op. Holding a single snapshot makes cross-generation coherence true by
25//! construction. The manager, limits, audit sink, and event source remain
26//! lifetime-immutable and live directly on [`BrokerState`].
27//!
28//! Today there is exactly one generation (id `1`); this iteration lands only the
29//! pinning plumbing: there is no reload trigger yet (`basil-y3e.2`).
30
31use std::sync::Arc;
32use std::sync::Mutex;
33use std::time::{Duration, Instant};
34
35use arc_swap::{ArcSwap, Guard};
36
37use crate::audit::{AuditLog, ReloadActor};
38use crate::catalog::policy::{Config, ResolvedPolicy};
39use crate::catalog::{Catalog, Pdp};
40use crate::core::crypto_provider::{ProviderAuditEvent, ProviderAuditOutcome};
41use crate::decision::DecisionRecord;
42use crate::event::EventSource;
43use crate::manager::{BackendManager, ManagerError};
44use crate::reload::ReloadInputs;
45use crate::revocation::JwtRevocationStore;
46
47/// The generation id assigned to the broker's first (startup) generation.
48///
49/// The id is a monotonic counter bumped on each successful reload; it starts at
50/// `1` so `0` can never be mistaken for a live generation.
51pub const INITIAL_GENERATION_ID: u64 = 1;
52
53/// One coherent snapshot of the reloadable policy surface.
54///
55/// A `Generation` bundles the [`Catalog`], [`ResolvedPolicy`] index, and
56/// export-resolved [`Config`] tables that together drive a [`Pdp`] decision, plus
57/// a monotonic [`id`](Generation::id) that names this snapshot in the audit trail.
58/// It is the unit that [`BrokerState`] swaps atomically on reload: an operation
59/// that pins one `Generation` sees an internally consistent
60/// `(catalog, policy, config)` triple for its entire lifetime, so a concurrent
61/// reload can never mix an old catalog with a new policy.
62#[derive(Debug)]
63pub struct Generation {
64    /// Monotonic generation id (starts at [`INITIAL_GENERATION_ID`], bumped on
65    /// each successful reload). Carried into the audit record.
66    id: u64,
67    catalog: Arc<Catalog>,
68    policy: ResolvedPolicy,
69    config: Config,
70}
71
72impl Generation {
73    /// Bundle a `(catalog, policy, config)` triple under a generation `id`.
74    #[must_use]
75    pub fn new(
76        id: u64,
77        catalog: impl Into<Arc<Catalog>>,
78        policy: ResolvedPolicy,
79        config: Config,
80    ) -> Self {
81        Self {
82            id,
83            catalog: catalog.into(),
84            policy,
85            config,
86        }
87    }
88
89    /// This generation's monotonic id (for the audit record / status probe).
90    #[must_use]
91    pub const fn id(&self) -> u64 {
92        self.id
93    }
94
95    /// A [`Pdp`] borrowing this generation's catalog/policy/config for one
96    /// decision.
97    ///
98    /// The PDP is `Copy` and holds only shared borrows into this generation, so
99    /// this is a cheap view, not a clone. The borrow ties the PDP to the
100    /// generation snapshot, keeping the whole decision coherent.
101    #[must_use]
102    pub fn pdp(&self) -> Pdp<'_> {
103        Pdp::new(&self.catalog, &self.policy, &self.config)
104    }
105
106    /// The export-resolved config tables (for `name(num)` audit rendering).
107    #[must_use]
108    pub const fn config(&self) -> &Config {
109        &self.config
110    }
111
112    /// This generation's resolved authorization policy.
113    #[must_use]
114    pub const fn policy(&self) -> &ResolvedPolicy {
115        &self.policy
116    }
117
118    /// This generation's loaded [`Catalog`].
119    ///
120    /// Used by the reload engine (`basil-y3e.2`) to compare the candidate's
121    /// restart-only routing shape against the currently-serving generation.
122    #[must_use]
123    pub fn catalog(&self) -> &Catalog {
124        &self.catalog
125    }
126}
127
128/// The default `max_encrypt_size`: 1 MiB (wire-protocol-v2 §2.1).
129pub const DEFAULT_MAX_ENCRYPT_SIZE: usize = 1024 * 1024;
130
131/// The default `max_payload_size`: 1 MiB.
132///
133/// Bounds the `set` value and the `import` key material broker-side
134/// (wire-protocol-v2 §2.1, "same mechanism" as the AEAD cap), returning
135/// `payload_too_large` instead of relying on the u32 frame ceiling (`vault-sao`).
136pub const DEFAULT_MAX_PAYLOAD_SIZE: usize = 1024 * 1024;
137
138/// The default rotation grace window, in key versions (`vault-xhq`).
139///
140/// A ciphertext/signature made up to this many versions back still
141/// decrypts/verifies. `1` honors the single previous version (plus the latest);
142/// raise it for a longer window, or set `0` to honor *only* the newest version
143/// (the compromise/panic setting).
144pub const DEFAULT_ROTATION_GRACE_VERSIONS: u32 = 1;
145
146/// Default SPIFFE SVID lifetime in seconds.
147pub const DEFAULT_SVID_TTL_SECS: u64 = 300;
148
149/// Broker-wide runtime limits + rotation policy, supplied by the binary at
150/// startup and shared (immutable) across connections.
151///
152/// These are *broker* knobs (not catalog/policy data): payload caps, SVID
153/// lifetime, and the rotation grace/retention windows the manager applies on
154/// `rotate` + the retention sweep (`vault-xhq` / `vault-uo1`).
155#[derive(Debug, Clone, Copy)]
156pub struct BrokerLimits {
157    /// Cap (bytes) on the `encrypt` plaintext **and** the `decrypt` ciphertext;
158    /// over-limit → `payload_too_large` (wire §2.1). Default
159    /// [`DEFAULT_MAX_ENCRYPT_SIZE`].
160    pub max_encrypt_size: usize,
161    /// Cap (bytes) on the `set` value and the `import` key material; over-limit →
162    /// `payload_too_large` (wire §2.1, "same mechanism" as the AEAD cap). This is
163    /// the broker-side max that replaces relying on the u32 frame ceiling
164    /// (`vault-sao`). Default [`DEFAULT_MAX_PAYLOAD_SIZE`].
165    pub max_payload_size: usize,
166    /// Rotation grace window in **key versions**: on `rotate`, the manager raises
167    /// the backend's `min_decryption_version` to `new_version - grace_versions`
168    /// (clamped at 1), so versions older than the window stop decrypting/verifying
169    /// while the in-window ones still do. Default
170    /// [`DEFAULT_ROTATION_GRACE_VERSIONS`].
171    pub grace_versions: u32,
172    /// Lifetime, in seconds, for SPIFFE SVIDs minted by the Workload API. Default
173    /// [`DEFAULT_SVID_TTL_SECS`].
174    pub svid_ttl_secs: u64,
175    /// Retention floor in **key versions**: [`BrokerLimits::retention_floor`]
176    /// raises the backend's `min_available_version` to
177    /// `latest - retain_versions` (clamped at 1), irreversibly pruning archived
178    /// key material below it. `None` disables the sweep (retain everything).
179    pub retain_versions: Option<u32>,
180}
181
182impl Default for BrokerLimits {
183    fn default() -> Self {
184        Self {
185            max_encrypt_size: DEFAULT_MAX_ENCRYPT_SIZE,
186            max_payload_size: DEFAULT_MAX_PAYLOAD_SIZE,
187            grace_versions: DEFAULT_ROTATION_GRACE_VERSIONS,
188            svid_ttl_secs: DEFAULT_SVID_TTL_SECS,
189            retain_versions: None,
190        }
191    }
192}
193
194impl BrokerLimits {
195    /// The grace floor (`min_decryption_version`) for a key now at `latest`:
196    /// `latest - grace_versions`, clamped to at least `1`.
197    #[must_use]
198    pub const fn grace_floor(&self, latest: u32) -> u32 {
199        let floor = latest.saturating_sub(self.grace_versions);
200        if floor < 1 { 1 } else { floor }
201    }
202
203    /// The retention floor (`min_available_version`) for a key now at `latest`,
204    /// or `None` when retention is disabled. `latest - retain_versions`, clamped
205    /// to at least `1` (never prunes the only/last version).
206    #[must_use]
207    pub const fn retention_floor(&self, latest: u32) -> Option<u32> {
208        match self.retain_versions {
209            Some(retain) => {
210                let floor = latest.saturating_sub(retain);
211                Some(if floor < 1 { 1 } else { floor })
212            }
213            None => None,
214        }
215    }
216}
217
218/// How long a computed readiness probe is reused before re-fanning-out to the
219/// backend (`basil-8nwy`).
220///
221/// The admin `Readiness` RPC runs one metadata/KV read **per catalog key** against
222/// the backend. It is ungated (any socket peer may call it), so without a cache a
223/// hostile local peer could hammer `basil ready` to amplify N backend reads. A
224/// short TTL bounds that fan-out to at most one probe per window while staying well
225/// under any sane liveness/readiness poll cadence, so a real readiness transition
226/// is still surfaced within [`READINESS_CACHE_TTL`]. A generation change
227/// (hot reload) invalidates the cache immediately regardless of the TTL.
228pub const READINESS_CACHE_TTL: Duration = Duration::from_secs(2);
229
230/// How long a computed JWKS document is reused before re-reading issuer public
231/// keys from the backend. A generation change invalidates the cache immediately.
232pub const JWKS_CACHE_TTL: Duration = Duration::from_secs(2);
233
234/// The coarse readiness category a probe resolved to: the proto-free core of the
235/// admin `Readiness` summary (the service layer maps it onto the wire enum).
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum ReadinessState {
238    /// Every `missing=error` key is present and the backend was reachable.
239    Ready,
240    /// At least one `missing=error` key's material is absent: its ops would fail
241    /// closed.
242    RequiredKeyMissing,
243    /// The backend was unreachable/rejecting during the probe (fail closed). The
244    /// per-key counts are unknown in this arm and reported as zero.
245    BackendUnreachable,
246}
247
248/// The non-secret outcome of one readiness probe.
249///
250/// Carries the coarse [`ReadinessState`] plus the key counts the admin summary
251/// reports. Absent keys are classified against the serving generation's catalog
252/// at probe time (`missing` is a reloadable dimension), so a cached outcome is
253/// reused only while it still matches the generation that produced it (see
254/// [`CachedReadiness`]); the serving generation id itself is stamped on the wire
255/// response by the caller.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub struct ReadinessOutcome {
258    /// The coarse readiness category.
259    pub state: ReadinessState,
260    /// Total catalog keys probed (`0` when the backend was unreachable).
261    pub keys_total: u32,
262    /// Keys whose material is present.
263    pub keys_present: u32,
264    /// Absent `missing=error` keys (the ones that block readiness).
265    pub keys_required_missing: u32,
266    /// Absent `missing=warn`/`generate` keys (which do not block readiness).
267    pub keys_optional_missing: u32,
268}
269
270impl ReadinessOutcome {
271    /// Whether the broker is ready (no required key absent, backend reachable).
272    #[must_use]
273    pub const fn ready(&self) -> bool {
274        matches!(self.state, ReadinessState::Ready)
275    }
276}
277
278/// A cached readiness probe: the [`ReadinessOutcome`] plus the generation it was
279/// computed for and the [`Instant`] after which it is stale (the TTL deadline).
280#[derive(Debug, Clone, Copy)]
281struct CachedReadiness {
282    outcome: ReadinessOutcome,
283    generation: u64,
284    expires_at: Instant,
285}
286
287/// A cached JWKS document, keyed by the serving generation.
288#[derive(Debug, Clone)]
289pub struct CachedJwks {
290    /// Serialized JWKS body.
291    pub body: Vec<u8>,
292    /// Strong `ETag` for `body`.
293    pub etag: String,
294    generation: u64,
295    expires_at: Instant,
296}
297
298/// The shared broker state: the loaded policy surface plus the backend manager.
299///
300/// Construct once with [`BrokerState::new`] and wrap in an `Arc`; the accept loop
301/// clones the `Arc` into every connection handler.
302#[derive(Debug)]
303pub struct BrokerState {
304    /// The active policy generation, swapped atomically on reload (`basil-y3e`).
305    /// Every RPC loads exactly one snapshot of this and pins the whole op to it.
306    generation: ArcSwap<Generation>,
307    manager: BackendManager,
308    /// A stable backend label reported in a `status` response. The manager does
309    /// not expose its backend kinds, so the binary supplies one at construction
310    /// (e.g. `"vault"`); `vault-i9j`'s `list`/metadata work can refine this.
311    backend_label: String,
312    /// The broker software version reported by `status`/`health`. The binary
313    /// (`basil-bin`) supplies its own `CARGO_PKG_VERSION` at construction via
314    /// [`BrokerState::with_version`], so the served version tracks the shipped
315    /// `basil` binary even if this library crate (`basil-core`) is versioned
316    /// separately. Defaults to this crate's version (the workspace version, the
317    /// two coincide today) when the binary does not set it.
318    agent_version: String,
319    /// Broker-wide payload caps + rotation grace/retention policy.
320    limits: BrokerLimits,
321    /// Optional JSONL audit sink (`vault-vq5`): when `Some`, every recorded
322    /// decision is also appended as one JSON line to an append-only file. `None`
323    /// disables file-audit (the `tracing` log is always emitted regardless). Held
324    /// behind an `Arc` because the sink is shared, immutable, and internally
325    /// synchronized; the field is cloneable into the per-op audit call.
326    audit: Option<Arc<AuditLog>>,
327    events: EventSource,
328    jwt_revocations: JwtRevocationStore,
329    /// The configured on-disk catalog/policy paths the SIGHUP reload engine
330    /// (`basil-y3e.2`) re-reads from. `None` disables reload (the broker has no
331    /// configured paths to re-read); a SIGHUP then only reopens the audit log.
332    reload_inputs: Option<ReloadInputs>,
333    /// A short TTL cache of the last admin readiness probe (`basil-8nwy`), so a
334    /// burst of ungated `Readiness` RPCs re-fans-out to the backend at most once
335    /// per [`READINESS_CACHE_TTL`] instead of per call. Guarded by a `Mutex`; the
336    /// backend probe is never run while the lock is held, and the cache is bypassed
337    /// when the serving generation has changed since the cached probe.
338    readiness_cache: Mutex<Option<CachedReadiness>>,
339    /// Short TTL cache for the unauthenticated network-facing JWKS endpoint.
340    jwks_cache: Mutex<Option<CachedJwks>>,
341    /// Serializes the reload engine's validate→swap sequence. SIGHUP and the
342    /// admin-reload RPC can fire concurrently; without this lock both could pin
343    /// generation N, both stamp N+1, and the staler candidate could overwrite
344    /// the newer one (a silent lost update). Holds no data: it only orders the
345    /// two triggers. Reloads are rare, so contention is irrelevant.
346    reload_lock: Mutex<()>,
347}
348
349impl BrokerState {
350    /// Bundle the loaded policy surface (the `load()` 4-tuple's first three
351    /// elements) with an already-constructed [`BackendManager`].
352    ///
353    /// `backend_label` is the name a `status` response advertises (the backend
354    /// `kind()`; with one backend today this is unambiguous). The manager is built
355    /// separately because building backends from credentials is the binary's job
356    /// (`vault-vh1`); this layer only routes + authorizes.
357    #[must_use]
358    pub fn new(
359        catalog: Catalog,
360        policy: ResolvedPolicy,
361        config: Config,
362        manager: BackendManager,
363        backend_label: impl Into<String>,
364    ) -> Self {
365        Self::with_limits(
366            Arc::new(catalog),
367            policy,
368            config,
369            manager,
370            backend_label,
371            BrokerLimits::default(),
372        )
373    }
374
375    /// Like [`BrokerState::new`] but with explicit broker [`BrokerLimits`] (the
376    /// AEAD payload cap + rotation grace/retention policy the binary threads from
377    /// its args). [`BrokerState::new`] uses [`BrokerLimits::default`].
378    #[must_use]
379    pub fn with_limits(
380        catalog: impl Into<Arc<Catalog>>,
381        policy: ResolvedPolicy,
382        config: Config,
383        manager: BackendManager,
384        backend_label: impl Into<String>,
385        limits: BrokerLimits,
386    ) -> Self {
387        let generation = Generation::new(INITIAL_GENERATION_ID, catalog, policy, config);
388        Self {
389            generation: ArcSwap::from_pointee(generation),
390            manager,
391            backend_label: backend_label.into(),
392            // Default to this crate's version; the binary overrides with its own
393            // via `with_version` so `status`/`health` report the `basil` binary's
394            // version, not `basil-core`'s.
395            agent_version: env!("CARGO_PKG_VERSION").to_string(),
396            limits,
397            audit: None,
398            events: EventSource::new(),
399            jwt_revocations: JwtRevocationStore::default(),
400            reload_inputs: None,
401            readiness_cache: Mutex::new(None),
402            jwks_cache: Mutex::new(None),
403            reload_lock: Mutex::new(()),
404        }
405    }
406
407    /// Attach a JSONL audit sink (`vault-vq5`), consuming and returning `self`.
408    ///
409    /// When set, [`BrokerState::audit`] appends one JSONL line per recorded
410    /// decision to the open append-only file; when this is never called, file
411    /// audit is disabled (`tracing` still logs every decision). The binary opens
412    /// the file once at startup and threads the [`AuditLog`] in here.
413    #[must_use]
414    pub fn with_audit_log(mut self, audit: Arc<AuditLog>) -> Self {
415        self.audit = Some(audit);
416        self
417    }
418
419    /// Attach the JWT-SVID revocation deny-list loaded at startup.
420    #[must_use]
421    pub fn with_jwt_revocations(mut self, jwt_revocations: JwtRevocationStore) -> Self {
422        self.jwt_revocations = jwt_revocations;
423        self
424    }
425
426    /// Set the broker software version reported by `status`/`health`.
427    ///
428    /// The binary (`basil-bin`) passes its own `env!("CARGO_PKG_VERSION")` so the
429    /// served version follows the shipped `basil` binary rather than this
430    /// library crate. Without this, the version defaults to `basil-core`'s.
431    #[must_use]
432    pub fn with_version(mut self, version: impl Into<String>) -> Self {
433        self.agent_version = version.into();
434        self
435    }
436
437    /// Record the configured on-disk catalog/policy paths so the SIGHUP reload
438    /// engine (`basil-y3e.2`) can re-read from the **same** paths startup used.
439    ///
440    /// Without this, [`BrokerState::reload_inputs`] is `None` and a reload fails
441    /// closed with `ReloadError::NoInputs` (a SIGHUP then only reopens the audit
442    /// log). The broker never reads catalog/policy from an unconfigured source.
443    #[must_use]
444    pub fn with_reload_inputs(mut self, inputs: ReloadInputs) -> Self {
445        self.reload_inputs = Some(inputs);
446        self
447    }
448
449    /// The configured catalog/policy paths the reload engine re-reads, if any.
450    #[must_use]
451    pub const fn reload_inputs(&self) -> Option<&ReloadInputs> {
452        self.reload_inputs.as_ref()
453    }
454
455    /// The lock the reload engine holds across its whole validate→swap sequence,
456    /// so concurrent triggers (SIGHUP + admin RPC) apply strictly one at a time
457    /// and generation ids stay monotonic with no lost update.
458    #[must_use]
459    pub const fn reload_lock(&self) -> &Mutex<()> {
460        &self.reload_lock
461    }
462
463    /// The id of the **currently serving** generation (observable for the status
464    /// probe, `basil-s7h`, and the reload audit). A monotonic counter bumped on
465    /// each successful reload.
466    #[must_use]
467    pub fn active_generation_id(&self) -> u64 {
468        self.load_generation().id()
469    }
470
471    /// Return the cached readiness outcome if one was computed for the
472    /// currently-serving generation within [`READINESS_CACHE_TTL`] (`basil-8nwy`).
473    ///
474    /// A cache hit lets the admin `Readiness` RPC answer **without** re-fanning-out
475    /// one backend read per catalog key: the amplification a hostile, ungated
476    /// socket peer could otherwise drive. The entry is bypassed (re-probe required)
477    /// when it has expired **or** when the serving generation has changed since it
478    /// was cached, so a hot reload that alters the key set can never be masked
479    /// longer than it takes to recompute, and a stale generation's counts are never
480    /// served. Returns `None` to mean "no usable cache; probe the backend".
481    ///
482    /// The returned outcome is generation-independent; the caller stamps the
483    /// current generation id onto the wire response.
484    #[must_use]
485    pub fn cached_readiness(&self) -> Option<ReadinessOutcome> {
486        let generation = self.active_generation_id();
487        let now = Instant::now();
488        // Copy the small entry out and release the lock immediately; the validity
489        // check (and any caller work) never runs while the mutex is held.
490        let cached = {
491            let guard = self.readiness_cache.lock().ok()?;
492            (*guard)?
493        };
494        (cached.generation == generation && now < cached.expires_at).then_some(cached.outcome)
495    }
496
497    /// Store a freshly-computed readiness `outcome` for `generation`, valid for
498    /// [`READINESS_CACHE_TTL`] from now (`basil-8nwy`). A subsequent
499    /// [`BrokerState::cached_readiness`] within that window (and on the same
500    /// generation) reuses it instead of re-probing the backend.
501    ///
502    /// A poisoned lock is swallowed (the cache is a best-effort optimization, never
503    /// a correctness or liveness dependency): the worst case is a missed cache and
504    /// one extra probe, never a panic on the serving path.
505    pub fn cache_readiness(&self, generation: u64, outcome: ReadinessOutcome) {
506        if let Ok(mut guard) = self.readiness_cache.lock() {
507            *guard = Some(CachedReadiness {
508                outcome,
509                generation,
510                expires_at: Instant::now() + READINESS_CACHE_TTL,
511            });
512        }
513    }
514
515    /// Return the cached JWKS document for the currently-serving generation.
516    #[must_use]
517    pub fn cached_jwks(&self) -> Option<CachedJwks> {
518        let generation = self.active_generation_id();
519        let now = Instant::now();
520        let cached = {
521            let guard = self.jwks_cache.lock().ok()?;
522            (*guard).clone()?
523        };
524        (cached.generation == generation && now < cached.expires_at).then_some(cached)
525    }
526
527    /// Store a freshly-built JWKS document for `generation`.
528    pub fn cache_jwks(&self, generation: u64, body: Vec<u8>, etag: String) {
529        if let Ok(mut guard) = self.jwks_cache.lock() {
530            *guard = Some(CachedJwks {
531                body,
532                etag,
533                generation,
534                expires_at: Instant::now() + JWKS_CACHE_TTL,
535            });
536        }
537    }
538
539    /// Atomically swap in a new [`Generation`], replacing the currently serving
540    /// one. The **only** mutation point of the reloadable policy surface.
541    ///
542    /// Callers (the reload engine, `basil-y3e.2`) must have already validated the
543    /// new generation; this is the unconditional store. In-flight operations that
544    /// pinned the previous generation via [`BrokerState::load_generation`] keep
545    /// seeing it until they drop their guard. The swap never disturbs an op
546    /// already in progress.
547    pub fn swap_generation(&self, generation: Arc<Generation>) {
548        self.generation.store(generation);
549    }
550
551    /// Snapshot the active [`Generation`] for the lifetime of one operation.
552    ///
553    /// This is the **one** point an RPC pins its generation: call it once at
554    /// request entry, then drive the whole op off the returned guard
555    /// ([`Generation::pdp`], [`Generation::config`], [`Generation::id`]) so the
556    /// `(catalog, policy, config)` triple stays coherent even if a concurrent
557    /// reload swaps in a newer generation mid-op. Hold the guard only as long as
558    /// the op needs it (it pins the snapshot from being reclaimed); if the
559    /// snapshot must survive an `.await`, clone the inner `Arc` out of the guard
560    /// rather than holding the guard across the suspension point.
561    #[must_use]
562    pub fn load_generation(&self) -> Guard<Arc<Generation>> {
563        self.generation.load()
564    }
565
566    /// Record one authorization decision (`vault-vq5`): emit it to `tracing`
567    /// (allow=info, deny=warn) **and**, when a JSONL audit sink is configured,
568    /// append it as one line to the append-only audit file.
569    ///
570    /// The file append is **best-effort**: an IO error is logged and swallowed by
571    /// the sink so an audit-disk problem can never block, deny, or panic the data
572    /// plane: the trustworthy decision already happened and is in `tracing`. This
573    /// is the single hook the handler calls for every gated op, allow or deny.
574    pub fn record_decision(&self, record: &DecisionRecord) {
575        record.record();
576        if let Some(audit) = &self.audit {
577            audit.append(record);
578        }
579    }
580
581    /// Record one software-custody provider operation (`wuj.7`): emit it to
582    /// `tracing` (failure/deny at `warn`, allow/success at `info`) and, when a
583    /// JSONL audit sink is configured, append the secret-free
584    /// [`ProviderAuditEvent`] JSON as one line. The event carries the selected
585    /// provider and algorithm and **never** any seed, signature, plaintext, or
586    /// ciphertext bytes. Best-effort like [`Self::record_decision`]: it can never
587    /// block, fail, or panic the data plane.
588    pub fn record_provider_event(&self, event: &ProviderAuditEvent<'_>) {
589        match event.outcome {
590            ProviderAuditOutcome::Failure | ProviderAuditOutcome::Deny => tracing::warn!(
591                event = "basil.audit.provider_operation",
592                op = event.op,
593                key = event.key_id,
594                algorithm = event.algorithm,
595                outcome = ?event.outcome,
596                reason = event.reason,
597                "software-custody provider operation",
598            ),
599            ProviderAuditOutcome::Allow | ProviderAuditOutcome::Success => tracing::info!(
600                event = "basil.audit.provider_operation",
601                op = event.op,
602                key = event.key_id,
603                algorithm = event.algorithm,
604                outcome = ?event.outcome,
605                reason = event.reason,
606                "software-custody provider operation",
607            ),
608        }
609        if let Some(audit) = &self.audit {
610            audit.append_value(&event.to_json_value());
611        }
612    }
613
614    /// Record a generation reload outcome (`basil-y3e.2`, `basil-atq`): emit it to
615    /// `tracing` and, when a JSONL audit sink is configured, append one
616    /// `basil.audit.reload` line.
617    ///
618    /// `outcome` is one of `"applied"` (a real swap happened, logged at `info`),
619    /// `"checked"` (an admin `--check` dry-run validated, no swap, `info`), or
620    /// `"rejected"` (the candidate failed; previous generation keeps serving,
621    /// `warn`). `reason` is a short, stable, non-secret token (e.g. `signal`,
622    /// `admin_rpc`, or a [`ReloadError`](crate::reload::ReloadError) audit token).
623    /// `actor` attributes the trigger ([`ReloadActor::Sighup`] for the signal path
624    /// or [`ReloadActor::Caller`] with the attested uid for the admin RPC), the
625    /// only field that differs in the audit trail between an otherwise-identical
626    /// SIGHUP and RPC reload (`basil-ftmc`). Like [`BrokerState::record_decision`],
627    /// the file append is best-effort and can never block, fail, or panic the broker.
628    pub fn record_reload(
629        &self,
630        previous_generation: u64,
631        new_generation: u64,
632        outcome: &str,
633        reason: &str,
634        actor: ReloadActor,
635    ) {
636        match outcome {
637            "applied" => tracing::info!(
638                event = "basil.audit.reload",
639                previous_generation,
640                generation = new_generation,
641                outcome,
642                reason,
643                "catalog/policy reload applied",
644            ),
645            "checked" => tracing::info!(
646                event = "basil.audit.reload",
647                previous_generation,
648                generation = new_generation,
649                outcome,
650                reason,
651                "catalog/policy reload dry-run validated; no swap",
652            ),
653            _ => tracing::warn!(
654                event = "basil.audit.reload",
655                previous_generation,
656                generation = new_generation,
657                outcome,
658                reason,
659                "catalog/policy reload rejected; previous generation still serving",
660            ),
661        }
662        if let Some(audit) = &self.audit {
663            audit.append_reload(previous_generation, new_generation, outcome, reason, actor);
664        }
665    }
666
667    /// The backend manager that routes an allowed op to its backend instance.
668    #[must_use]
669    pub const fn manager(&self) -> &BackendManager {
670        &self.manager
671    }
672
673    /// The backend label advertised in a `status` response.
674    #[must_use]
675    pub fn backend_label(&self) -> &str {
676        &self.backend_label
677    }
678
679    /// The broker software version advertised in `status`/`health` responses.
680    #[must_use]
681    pub fn agent_version(&self) -> &str {
682        &self.agent_version
683    }
684
685    /// The broker-wide payload caps + rotation grace/retention policy.
686    #[must_use]
687    pub const fn limits(&self) -> BrokerLimits {
688        self.limits
689    }
690
691    /// Shared broker event source.
692    #[must_use]
693    pub const fn events(&self) -> &EventSource {
694        &self.events
695    }
696
697    /// JWT-SVID revocation deny-list shared by Workload API validation.
698    #[must_use]
699    pub const fn jwt_revocations(&self) -> &JwtRevocationStore {
700        &self.jwt_revocations
701    }
702
703    /// Re-read the catalog-backed JWT-SVID deny-list and merge it into memory.
704    ///
705    /// This stays outside the hot-swapped [`Generation`]: the deny-list is mutable
706    /// serving state, and refresh uses union semantics so a live revocation cannot
707    /// be clobbered by a concurrent reload.
708    ///
709    /// # Errors
710    ///
711    /// Returns [`ManagerError`] if the configured store is malformed, unreadable,
712    /// or the live deny-list locks are poisoned. On error the previous in-memory
713    /// set remains serving.
714    pub async fn refresh_jwt_revocations(&self) -> Result<(), ManagerError> {
715        self.jwt_revocations
716            .refresh_from_manager(&self.manager)
717            .await
718    }
719
720    /// Persist and publish a JWT-SVID revocation.
721    ///
722    /// # Errors
723    ///
724    /// Returns [`crate::manager::ManagerError`] if the optional catalog-backed
725    /// deny-list write fails.
726    pub async fn revoke_jwt_svid(
727        &self,
728        trust_domain: &str,
729        jti: &str,
730        expires_at_unix: u64,
731    ) -> Result<(), crate::manager::ManagerError> {
732        self.jwt_revocations
733            .insert(trust_domain, jti, expires_at_unix)?;
734        self.jwt_revocations.persist(&self.manager).await?;
735        self.events.revoked(trust_domain, jti);
736        Ok(())
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use std::collections::BTreeMap;
743
744    use arc_swap::ArcSwap;
745
746    use super::{Generation, INITIAL_GENERATION_ID};
747    use crate::catalog::Catalog;
748    use crate::catalog::policy::{Config, Op, ResolvedPolicy};
749
750    /// A minimal, key-less catalog: enough to drive a `Pdp` decision (every key
751    /// is unknown → default-deny), without standing up a backend manager.
752    fn empty_catalog() -> Catalog {
753        Catalog {
754            schema_version: 1,
755            backends: BTreeMap::new(),
756            keys: BTreeMap::new(),
757        }
758    }
759
760    fn generation(id: u64) -> Generation {
761        Generation::new(
762            id,
763            empty_catalog(),
764            ResolvedPolicy::default(),
765            Config::default(),
766        )
767    }
768
769    #[test]
770    fn initial_generation_id_is_one() {
771        assert_eq!(INITIAL_GENERATION_ID, 1);
772        assert_eq!(generation(INITIAL_GENERATION_ID).id(), 1);
773    }
774
775    /// A single `load()` guard yields ONE coherent generation: the id, the PDP
776    /// it hands out, and its config all come from the same snapshot. This is the
777    /// per-request pinning seam: an op drives every decision off one guard.
778    #[test]
779    fn one_snapshot_is_internally_coherent() {
780        let swap = ArcSwap::from_pointee(generation(7));
781        let pinned = swap.load();
782
783        // The id, the PDP, and the config are all read from the SAME guard, so
784        // they cannot disagree even if a concurrent swap lands mid-op.
785        assert_eq!(pinned.id(), 7);
786        // The pinned catalog is the empty one, so any key is unknown → deny.
787        assert!(
788            pinned
789                .pdp()
790                .explain_subject("svc.missing", Op::Get, "any.key")
791                .decision
792                .is_deny()
793        );
794        // The pinned config is the default (no user names), same snapshot.
795        assert!(pinned.config().names.users.is_empty());
796    }
797
798    /// With no swap between two loads, both observe the same generation id: the
799    /// property a future reload (`basil-y3e.2`) will deliberately break by
800    /// bumping the id on swap. Today: byte-identical, monotonic, one generation.
801    #[test]
802    fn repeated_loads_without_swap_see_same_generation() {
803        let swap = ArcSwap::from_pointee(generation(INITIAL_GENERATION_ID));
804        let first = swap.load().id();
805        let second = swap.load().id();
806        assert_eq!(first, second);
807        assert_eq!(first, INITIAL_GENERATION_ID);
808    }
809
810    /// Sanity check that an `ArcSwap` swap is observable by a *later* load while a
811    /// guard taken *before* the swap still sees the old generation: the coherence
812    /// guarantee the per-request pin relies on. (No production swap path exists
813    /// yet; this exercises the primitive the reload engine will use.)
814    #[test]
815    fn pinned_guard_survives_a_later_swap() {
816        let swap = ArcSwap::from_pointee(generation(1));
817        let pinned = swap.load();
818        assert_eq!(pinned.id(), 1);
819
820        swap.store(std::sync::Arc::new(generation(2)));
821
822        // The guard taken before the swap still names the old generation.
823        assert_eq!(pinned.id(), 1);
824        // A fresh load sees the new one.
825        assert_eq!(swap.load().id(), 2);
826    }
827}