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