basil_core/core/reload.rs
1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Signal-driven hot reload of the catalog/policy **generation** (`basil-y3e`).
6//!
7//! [`reload_generation`] is the single, fail-closed reload engine shared by the
8//! SIGHUP handler and (later) the permission-scoped gRPC admin-reload follow-on
9//! (`basil-atq`). It re-reads the catalog/policy from the **same on-disk paths**
10//! the broker was started with (never from the wire), runs the **full**
11//! startup/`check` validation on the candidate, enforces that only reloadable
12//! dimensions changed, and (only on success) atomically swaps in a new
13//! [`Generation`] with a bumped id. On any failure it does **not** swap: the
14//! previous generation keeps serving and the rejection is returned to the caller
15//! (the SIGHUP handler audits it). It never panics or exits.
16//!
17//! # Reloadable vs restart-only
18//!
19//! The reloadable surface is the **content** the [`Pdp`](crate::catalog::Pdp) and
20//! the audit trail consume: the entire policy (rules / roles / name + membership
21//! tables) and the per-key *authorization* attributes: `writable`, `class`,
22//! `labels`, `description`, `missing`. The **routing shape** is restart-only:
23//! the [`BackendManager`](crate::manager::BackendManager) and the live backend
24//! instances were built from the sealed bundle at startup, so adding/removing a
25//! backend, or changing any key's `backend`/`path`/`engine`/`key_type`/
26//! `public_path`, needs a re-unlock and is rejected here (the Nix module routes
27//! such edits to `ExecStart`, i.e. a restart). [`routing_shape`] captures exactly
28//! the dimensions baked into the manager; a candidate whose shape differs from the
29//! running generation is rejected with [`ReloadError::RoutingShapeChanged`].
30//!
31//! # Non-mutating
32//!
33//! Reload is **non-mutating**: it validates (and the loader's guardrails run) but
34//! it performs **no** backend I/O and **no** CSPRNG side effects: it never
35//! reconciles or generates missing material on the signal path. A candidate that
36//! adds a `missing:error` key whose material is absent is *accepted* (its routing
37//! shape is unchanged by construction, since a new key would change the shape and
38//! be rejected anyway); a `missing:error` key that already exists in both
39//! generations simply keeps failing closed at use if its material is absent. The
40//! routing-shape guard means a reload can only ever change a *pre-existing* key's
41//! authorization attributes, never introduce a new key/backend that would demand
42//! fresh material, so there is no missing-material decision to make on the signal
43//! path beyond what startup reconcile already settled.
44
45use std::collections::{BTreeMap, BTreeSet};
46use std::sync::Arc;
47
48use crate::catalog::loader::LoadError;
49use crate::catalog::schema::{BackendKind, Capability, Class, Engine, KeyAlgorithm};
50use crate::catalog::{Catalog, Config, ResolvedPolicy, load};
51use crate::state::{BrokerState, Generation};
52
53/// The on-disk inputs a [`reload_generation`] re-reads: the configured catalog
54/// and policy JSON paths the broker was started with.
55///
56/// Stored on [`BrokerState`] at construction so the reload engine reads from the
57/// **same** paths startup used, never from anywhere else, never from the wire.
58#[derive(Debug, Clone)]
59pub struct ReloadInputs {
60 /// Path to the exported catalog JSON (the key inventory + routing table).
61 pub catalog_path: std::path::PathBuf,
62 /// Path to the exported policy JSON (the authorization allow-list).
63 pub policy_path: std::path::PathBuf,
64}
65
66/// The result of a **successful** [`reload_generation`].
67///
68/// Carries the old → new generation ids plus summary counts so the SIGHUP handler
69/// (and the future gRPC admin-reload, `basil-atq`) can log/return what changed.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct ReloadOutcome {
72 /// The generation id that was serving before the swap.
73 pub previous_generation: u64,
74 /// The generation id now serving after the atomic swap.
75 pub new_generation: u64,
76 /// Number of catalog keys in the new generation.
77 pub key_count: usize,
78 /// Number of resolved policy allow-grants in the new generation.
79 pub grant_count: usize,
80}
81
82/// Why a [`reload_generation`] was **rejected**. On any of these the previous
83/// generation keeps serving (fail closed); none of them swap.
84#[derive(Debug, thiserror::Error)]
85pub enum ReloadError {
86 /// The catalog file could not be re-read from its configured path.
87 #[error("reading catalog from {path}: {source}")]
88 ReadCatalog {
89 /// The catalog path that failed to read.
90 path: String,
91 /// The underlying IO error.
92 source: std::io::Error,
93 },
94
95 /// The policy file could not be re-read from its configured path.
96 #[error("reading policy from {path}: {source}")]
97 ReadPolicy {
98 /// The policy path that failed to read.
99 path: String,
100 /// The underlying IO error.
101 source: std::io::Error,
102 },
103
104 /// The candidate catalog/policy failed the full startup/`check` validation
105 /// (`load`, including the JWT-SVID issuer-alg and `publicPath` guardrails).
106 #[error("validating reloaded catalog/policy: {0}")]
107 Validate(#[from] LoadError),
108
109 /// The candidate changed a **restart-only** routing dimension (a backend was
110 /// added/removed/repathed, or a key's `backend`/`path`/`engine`/`key_type`/
111 /// `public_path` changed). Such an edit needs a re-unlock and is rejected on
112 /// the reload path; apply it via a restart instead.
113 #[error("reload touches a restart-only routing dimension: {0}")]
114 RoutingShapeChanged(String),
115
116 /// The broker was constructed without [`ReloadInputs`] (no configured
117 /// catalog/policy paths), so it has nothing to re-read. A reload is a no-op
118 /// fail-closed rather than reading from an unknown source.
119 #[error("reload unavailable: broker has no configured catalog/policy paths")]
120 NoInputs,
121}
122
123impl ReloadError {
124 /// A short, stable, non-secret reason token for the audit trail.
125 #[must_use]
126 pub const fn audit_reason(&self) -> &'static str {
127 match self {
128 Self::ReadCatalog { .. } => "catalog_read_failed",
129 Self::ReadPolicy { .. } => "policy_read_failed",
130 Self::Validate(_) => "validation_failed",
131 Self::RoutingShapeChanged(_) => "routing_shape_changed",
132 Self::NoInputs => "no_reload_inputs",
133 }
134 }
135}
136
137/// The restart-only **routing shape** of one backend: everything the
138/// [`BackendManager`](crate::manager::BackendManager) and capability check bake in
139/// at startup. Two generations may only differ in reloadable content if their
140/// routing shapes are equal.
141#[derive(Debug, PartialEq, Eq)]
142struct BackendShape {
143 kind: BackendKind,
144 addr: String,
145 engines: Vec<Engine>,
146 capabilities: Vec<Capability>,
147 requires: Vec<Capability>,
148}
149
150/// The restart-only routing shape of one key: the dimensions that select a
151/// backend instance, a backend-native locator, and the materialize footprint.
152/// `writable` / `class`-surface authorization is *not* here (those are
153/// reloadable), but `class` itself selects the op surface + engine inference and
154/// the materialize arm, so it is part of the shape.
155#[derive(Debug, PartialEq, Eq)]
156struct KeyShape {
157 class: Class,
158 key_type: Option<KeyAlgorithm>,
159 backend: String,
160 engine: Option<Engine>,
161 path: String,
162 public_path: Option<String>,
163}
164
165/// Project a catalog onto its restart-only routing shape: the backend set and,
166/// per key, the routing/materialize dimensions. Equal shapes ⇒ the live manager
167/// and backends still route the new generation correctly; a differing shape needs
168/// a restart.
169fn routing_shape(
170 catalog: &Catalog,
171) -> (BTreeMap<String, BackendShape>, BTreeMap<String, KeyShape>) {
172 let backends = catalog
173 .backends
174 .iter()
175 .map(|(name, b)| {
176 (
177 name.clone(),
178 BackendShape {
179 kind: b.kind,
180 addr: b.addr.clone(),
181 engines: b.engines.clone(),
182 capabilities: b.capabilities.clone(),
183 requires: b.requires.clone(),
184 },
185 )
186 })
187 .collect();
188 let keys = catalog
189 .keys
190 .iter()
191 .map(|(name, k)| {
192 (
193 name.clone(),
194 KeyShape {
195 class: k.class,
196 key_type: k.key_type,
197 backend: k.backend.clone(),
198 engine: k.engine,
199 path: k.path.clone(),
200 public_path: k.public_path.clone(),
201 },
202 )
203 })
204 .collect();
205 (backends, keys)
206}
207
208/// Reject the candidate if it touches any restart-only routing dimension.
209///
210/// Compares the candidate's routing shape against the **currently serving**
211/// generation's catalog. A backend added/removed/repathed, or any key's
212/// `backend`/`path`/`engine`/`key_type`/`public_path` changed (or a key added/removed,
213/// which changes the key set, hence the shape), is restart-only.
214fn ensure_reloadable(current: &Catalog, candidate: &Catalog) -> Result<(), ReloadError> {
215 let (cur_backends, cur_keys) = routing_shape(current);
216 let (new_backends, new_keys) = routing_shape(candidate);
217 if cur_backends != new_backends {
218 return Err(ReloadError::RoutingShapeChanged(
219 "the backend set or a backend's kind/addr/engines/capabilities/requires changed"
220 .to_string(),
221 ));
222 }
223 if cur_keys != new_keys {
224 return Err(ReloadError::RoutingShapeChanged(
225 "a key was added/removed or a key's backend/path/engine/key_type/public_path changed"
226 .to_string(),
227 ));
228 }
229 Ok(())
230}
231
232fn spiffe_bundle_publishers(catalog: &Catalog) -> BTreeMap<String, (String, String)> {
233 catalog
234 .keys
235 .iter()
236 .filter_map(|(name, entry)| {
237 let svid_kind = entry.labels.get("svid_kind")?;
238 if !matches!(svid_kind, "jwt" | "x509") {
239 return None;
240 }
241 let trust_domain = entry.labels.get("trust_domain")?;
242 Some((
243 name.clone(),
244 (svid_kind.to_string(), trust_domain.to_string()),
245 ))
246 })
247 .collect()
248}
249
250fn bundle_changed_trust_domains(current: &Catalog, candidate: &Catalog) -> Vec<String> {
251 let current_publishers = spiffe_bundle_publishers(current);
252 let candidate_publishers = spiffe_bundle_publishers(candidate);
253 if current_publishers == candidate_publishers {
254 return Vec::new();
255 }
256
257 current_publishers
258 .values()
259 .chain(candidate_publishers.values())
260 .map(|(_, trust_domain)| trust_domain.clone())
261 .collect::<BTreeSet<_>>()
262 .into_iter()
263 .collect()
264}
265
266/// The fully-validated candidate generation produced by [`validate_candidate`]:
267/// the loaded surface (ready to install) plus the [`ReloadOutcome`] the swap would
268/// report. The dry-run path discards the surface and keeps only the outcome; the
269/// real reload installs the surface.
270struct ValidatedCandidate {
271 catalog: Catalog,
272 policy: ResolvedPolicy,
273 config: Config,
274 outcome: ReloadOutcome,
275 bundle_changed_trust_domains: Vec<String>,
276}
277
278/// Re-read the configured catalog/policy, run the **full** startup/`check`
279/// validation, and enforce that only reloadable dimensions changed, all **without
280/// swapping**. This is the single validation path shared by the real reload and
281/// the `--check` dry-run, so a dry-run can never diverge from what a real reload
282/// would accept (the same anti-divergence discipline the PDP's `decide`/`explain`
283/// share).
284///
285/// It is non-mutating (no backend I/O, no CSPRNG, no generation swap) and never
286/// panics. The returned [`ReloadOutcome`] reports the *would-be* generation ids
287/// and counts; it is identical to what [`reload_generation`] reports after a
288/// successful swap.
289///
290/// # Errors
291///
292/// Returns a [`ReloadError`] when the broker has no configured paths
293/// ([`ReloadError::NoInputs`]), a file cannot be re-read, the candidate fails
294/// validation ([`ReloadError::Validate`]), or it changes a restart-only routing
295/// dimension ([`ReloadError::RoutingShapeChanged`]).
296fn validate_candidate(state: &BrokerState) -> Result<ValidatedCandidate, ReloadError> {
297 let inputs = state.reload_inputs().ok_or(ReloadError::NoInputs)?;
298
299 let catalog_json = std::fs::read_to_string(&inputs.catalog_path).map_err(|source| {
300 ReloadError::ReadCatalog {
301 path: inputs.catalog_path.display().to_string(),
302 source,
303 }
304 })?;
305 let policy_json =
306 std::fs::read_to_string(&inputs.policy_path).map_err(|source| ReloadError::ReadPolicy {
307 path: inputs.policy_path.display().to_string(),
308 source,
309 })?;
310
311 // Full startup/`check` validation: load() runs every §5 hard-error check
312 // including validate_jwt_svid_issuer_alg and the publicPath guardrail.
313 let (catalog, policy, config, warnings) = load(&catalog_json, &policy_json)?;
314 for w in &warnings {
315 tracing::warn!(warning = %w, "reload: catalog/policy load warning");
316 }
317
318 // Pin the currently-serving generation to (a) compare routing shape against,
319 // and (b) read the previous id to bump from: one coherent snapshot.
320 let current = state.load_generation();
321 ensure_reloadable(current.catalog(), &catalog)?;
322
323 let previous_generation = current.id();
324 let new_generation = previous_generation.saturating_add(1);
325 let bundle_changed_trust_domains = bundle_changed_trust_domains(current.catalog(), &catalog);
326 let outcome = ReloadOutcome {
327 previous_generation,
328 new_generation,
329 key_count: catalog.keys.len(),
330 grant_count: policy.grant_count(),
331 };
332
333 Ok(ValidatedCandidate {
334 catalog,
335 policy,
336 config,
337 outcome,
338 bundle_changed_trust_domains,
339 })
340}
341
342/// Validate the candidate catalog/policy **without** swapping (the `--check`
343/// dry-run, basil-atq).
344///
345/// Runs the *identical* validation [`reload_generation`] runs (re-read from disk,
346/// full `load()` validation, and the restart-only routing-shape guard) but
347/// performs **no** generation swap: the currently-serving generation is untouched.
348/// The returned [`ReloadOutcome`] reports what a real reload *would* apply (the
349/// would-be new generation id + counts).
350///
351/// # Errors
352///
353/// The same [`ReloadError`] set as [`reload_generation`]; on any error the running
354/// generation keeps serving (it was never going to change here regardless).
355pub fn check_reload(state: &BrokerState) -> Result<ReloadOutcome, ReloadError> {
356 validate_candidate(state).map(|c| c.outcome)
357}
358
359/// Re-read the configured catalog/policy, validate the candidate, enforce that
360/// only reloadable dimensions changed, and on success atomically swap in a new
361/// [`Generation`] with a bumped id.
362///
363/// This is the **one** fail-closed reload code path, shared by the SIGHUP handler
364/// and the gRPC admin-reload follow-on (`basil-atq`). It is non-mutating up to the
365/// final swap (no backend I/O, no CSPRNG) and never panics. The validation it runs
366/// is exactly [`check_reload`]'s (they share [`validate_candidate`]), so a
367/// dry-run that passes guarantees the real reload's validation passes too.
368///
369/// # Errors
370///
371/// Returns a [`ReloadError`] (without swapping, so the previous generation keeps
372/// serving) when the broker has no configured paths ([`ReloadError::NoInputs`]),
373/// a file cannot be re-read, the candidate fails validation
374/// ([`ReloadError::Validate`]), or the candidate changes a restart-only routing
375/// dimension ([`ReloadError::RoutingShapeChanged`]).
376pub fn reload_generation(state: &BrokerState) -> Result<ReloadOutcome, ReloadError> {
377 // Serialize the whole validate→swap sequence: SIGHUP and the admin RPC can
378 // trigger concurrently, and without this two reloads could both pin
379 // generation N, both stamp N+1, and let the staler candidate silently
380 // overwrite the newer one. A poisoned lock is recovered: it holds no data,
381 // it only orders the triggers.
382 let _reload_guard = state
383 .reload_lock()
384 .lock()
385 .unwrap_or_else(std::sync::PoisonError::into_inner);
386 let candidate = validate_candidate(state)?;
387 let ValidatedCandidate {
388 catalog,
389 policy,
390 config,
391 outcome,
392 bundle_changed_trust_domains,
393 } = candidate;
394
395 let next = Generation::new(outcome.new_generation, Arc::new(catalog), policy, config);
396 state.swap_generation(Arc::new(next));
397 for trust_domain in bundle_changed_trust_domains {
398 state.events().bundle_changed(trust_domain);
399 }
400
401 Ok(outcome)
402}
403
404#[cfg(test)]
405mod tests {
406 use std::collections::BTreeMap;
407 use std::sync::Arc;
408
409 use async_trait::async_trait;
410 use basil_proto::KeyType;
411
412 use super::{ReloadError, ReloadInputs, check_reload, reload_generation};
413 use crate::backend::{Backend, BackendError, NewKey};
414 use crate::catalog::load;
415 use crate::manager::BackendManager;
416 use crate::state::{BrokerState, INITIAL_GENERATION_ID};
417
418 /// A no-op backend: reload is non-mutating and never calls the backend, so the
419 /// required trait methods all fail closed (the manager only needs them present
420 /// to satisfy `Backend`).
421 struct NoopBackend;
422
423 #[async_trait]
424 impl Backend for NoopBackend {
425 fn kind(&self) -> &'static str {
426 "noop"
427 }
428 async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
429 Err(BackendError::Unsupported("new_key"))
430 }
431 async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
432 Err(BackendError::Unsupported("public_key"))
433 }
434 async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
435 Err(BackendError::Unsupported("sign"))
436 }
437 async fn verify(
438 &self,
439 _key_id: &str,
440 _message: &[u8],
441 _signature: &[u8],
442 ) -> Result<bool, BackendError> {
443 Err(BackendError::Unsupported("verify"))
444 }
445 }
446
447 /// A one-key, one-backend catalog. `writable` is reloadable; the routing shape
448 /// (`backend`/`path`/`engine`/`key_type`) is fixed across the variants below.
449 fn catalog_json(writable: bool) -> String {
450 format!(
451 r#"{{
452 "schemaVersion": 1,
453 "backends": {{ "bao": {{ "kind": "vault", "addr": "http://127.0.0.1:8200" }} }},
454 "keys": {{
455 "web.signer": {{
456 "class": "asymmetric", "keyType": "ed25519", "backend": "bao",
457 "path": "signer", "writable": {writable}, "description": "a signer"
458 }}
459 }}
460 }}"#
461 )
462 }
463
464 /// A catalog whose key routes to a DIFFERENT path: a restart-only change.
465 fn catalog_json_repathed() -> String {
466 r#"{
467 "schemaVersion": 1,
468 "backends": { "bao": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
469 "keys": {
470 "web.signer": {
471 "class": "asymmetric", "keyType": "ed25519", "backend": "bao",
472 "path": "signer-v2", "writable": true, "description": "a signer"
473 }
474 }
475 }"#
476 .to_string()
477 }
478
479 fn policy_json(grant_sign: bool) -> String {
480 let rules = if grant_sign {
481 r#"[ { "id": "r1", "subjects": ["svc.web"], "action": ["op:sign"], "target": ["web.signer"] } ]"#
482 } else {
483 "[]"
484 };
485 format!(
486 r#"{{
487 "schemaVersion": 2,
488 "subjects": {{ "svc.web": {{ "allOf": [ {{ "kind": "unix", "uid": 1000 }} ] }} }},
489 "roles": {{}},
490 "rules": {rules},
491 "config": {{}}
492 }}"#
493 )
494 }
495
496 /// Build a [`BrokerState`] from catalog/policy JSON written to temp files, with
497 /// the reload inputs pointed at those files so the engine re-reads them.
498 fn state_with_files(catalog: &str, policy: &str) -> (Arc<BrokerState>, ReloadInputs) {
499 let dir = std::env::temp_dir().join(format!(
500 "basil-reload-test-{}-{}",
501 std::process::id(),
502 uuid::Uuid::new_v4()
503 ));
504 std::fs::create_dir_all(&dir).expect("create temp dir");
505 let catalog_path = dir.join("catalog.json");
506 let policy_path = dir.join("policy.json");
507 std::fs::write(&catalog_path, catalog).expect("write catalog");
508 std::fs::write(&policy_path, policy).expect("write policy");
509
510 let (cat, pol, cfg, warnings) = load(catalog, policy).expect("fixture loads");
511 assert!(warnings.is_empty());
512 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
513 backends.insert("bao".into(), Box::new(NoopBackend));
514 let manager = BackendManager::new(cat.clone(), backends).expect("manager builds");
515 let inputs = ReloadInputs {
516 catalog_path,
517 policy_path,
518 };
519 let state = Arc::new(
520 BrokerState::new(cat, pol, cfg, manager, "noop").with_reload_inputs(inputs.clone()),
521 );
522 (state, inputs)
523 }
524
525 fn write_files(inputs: &ReloadInputs, catalog: &str, policy: &str) {
526 std::fs::write(&inputs.catalog_path, catalog).expect("rewrite catalog");
527 std::fs::write(&inputs.policy_path, policy).expect("rewrite policy");
528 }
529
530 /// A valid reload (a reloadable-dimension edit) swaps to a new generation id,
531 /// and a guard pinned BEFORE the swap still sees the old generation while a
532 /// fresh load sees the new one: the reload-between-two-reads coherence the
533 /// pinning plumbing (y3e.1) could not exercise without a trigger.
534 #[test]
535 fn valid_reload_swaps_generation_and_stays_coherent() {
536 let (state, inputs) = state_with_files(&catalog_json(false), &policy_json(false));
537 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
538
539 // An in-flight op pins the current generation BEFORE the reload.
540 let pinned = state.load_generation();
541 assert_eq!(pinned.id(), INITIAL_GENERATION_ID);
542
543 // Edit a reloadable dimension (flip writable + add a sign grant).
544 write_files(&inputs, &catalog_json(true), &policy_json(true));
545 let outcome = reload_generation(&state).expect("valid reload applies");
546
547 assert_eq!(outcome.previous_generation, INITIAL_GENERATION_ID);
548 assert_eq!(outcome.new_generation, INITIAL_GENERATION_ID + 1);
549 assert_eq!(outcome.key_count, 1);
550 assert_eq!(outcome.grant_count, 1);
551
552 // The pre-swap pin still sees the OLD generation (coherent in-flight read);
553 // a fresh load sees the NEW one.
554 assert_eq!(pinned.id(), INITIAL_GENERATION_ID);
555 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID + 1);
556 }
557
558 /// An invalid candidate (malformed policy) is REJECTED, the previous
559 /// generation keeps serving, and the engine never panics.
560 #[test]
561 fn invalid_policy_is_rejected_and_previous_generation_keeps_serving() {
562 let (state, inputs) = state_with_files(&catalog_json(true), &policy_json(true));
563
564 // Corrupt the policy: reference a role that is not declared (§5 hard error
565 // UnknownRole), the catalog is unchanged, so this isolates a *validation*
566 // rejection from the routing-shape guard.
567 write_files(
568 &inputs,
569 &catalog_json(true),
570 r#"{ "schemaVersion": 2, "subjects": { "svc.web": { "allOf": [ { "kind": "unix", "uid": 1000 } ] } }, "roles": {}, "rules": [ { "id": "bad", "subjects": ["svc.web"], "action": ["role:nonexistent"], "target": ["web.signer"] } ], "config": {} }"#,
571 );
572
573 let err = reload_generation(&state).expect_err("malformed policy rejected");
574 assert!(matches!(err, ReloadError::Validate(_)));
575 assert_eq!(err.audit_reason(), "validation_failed");
576 // Previous generation untouched.
577 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
578 }
579
580 /// A non-profile JWT-SVID issuer candidate is rejected: the loader's fail-closed
581 /// issuer-alg guardrail runs on the reload path (validation), so the broker
582 /// never swaps in a generation that would mint SPIFFE-rejected tokens.
583 #[test]
584 fn non_profile_jwt_svid_issuer_is_rejected_on_reload() {
585 // Base: an RSA JWT-SVID issuer (loads at startup).
586 let base_catalog = r#"{
587 "schemaVersion": 1,
588 "backends": { "bao": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
589 "keys": {
590 "spiffe.jwt": {
591 "class": "asymmetric", "keyType": "rsa-2048", "backend": "bao", "path": "jwt",
592 "labels": ["svid_kind=jwt", "trust_domain=example.org"],
593 "writable": false, "description": "jwt issuer"
594 }
595 }
596 }"#;
597 let (state, inputs) = state_with_files(base_catalog, &policy_json(false));
598
599 // Candidate flips the issuer to ed25519 (EdDSA): a non-profile alg.
600 let bad_catalog = base_catalog.replace("rsa-2048", "ed25519");
601 write_files(&inputs, &bad_catalog, &policy_json(false));
602
603 let err = reload_generation(&state).expect_err("non-profile jwt issuer rejected");
604 // It is caught (either by the alg guardrail in validation, or, since the
605 // key_type is part of the routing shape, by the restart-only guard);
606 // either way the reload fails closed and the prior generation serves on.
607 assert!(matches!(
608 err,
609 ReloadError::Validate(_) | ReloadError::RoutingShapeChanged(_)
610 ));
611 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
612 }
613
614 /// A restart-only edit (a key repathed to a different backend locator) is
615 /// rejected: the live manager/backends cannot re-route without a restart.
616 #[test]
617 fn restart_only_routing_change_is_rejected() {
618 let (state, inputs) = state_with_files(&catalog_json(true), &policy_json(true));
619 write_files(&inputs, &catalog_json_repathed(), &policy_json(true));
620
621 let err = reload_generation(&state).expect_err("repath rejected");
622 assert!(matches!(err, ReloadError::RoutingShapeChanged(_)));
623 assert_eq!(err.audit_reason(), "routing_shape_changed");
624 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
625 }
626
627 /// `check_reload` (the `--check` dry-run) validates the candidate and reports
628 /// the would-be outcome WITHOUT swapping: the serving generation id is
629 /// unchanged, and a subsequent real reload applies the very same outcome.
630 #[test]
631 fn check_reload_validates_without_swapping() {
632 let (state, inputs) = state_with_files(&catalog_json(false), &policy_json(false));
633 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
634
635 write_files(&inputs, &catalog_json(true), &policy_json(true));
636 let dry = check_reload(&state).expect("dry-run validates");
637 assert_eq!(dry.previous_generation, INITIAL_GENERATION_ID);
638 assert_eq!(dry.new_generation, INITIAL_GENERATION_ID + 1);
639 assert_eq!(dry.key_count, 1);
640 assert_eq!(dry.grant_count, 1);
641 // The serving generation is UNCHANGED by the dry-run.
642 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
643
644 // A real reload now applies exactly what the dry-run previewed.
645 let applied = reload_generation(&state).expect("real reload applies");
646 assert_eq!(applied, dry);
647 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID + 1);
648 }
649
650 /// A rejected candidate is rejected identically by the dry-run and the real
651 /// reload, and neither swaps: the dry-run never diverges from enforcement.
652 #[test]
653 fn check_reload_rejects_what_real_reload_rejects() {
654 let (state, inputs) = state_with_files(&catalog_json(true), &policy_json(true));
655 write_files(&inputs, &catalog_json_repathed(), &policy_json(true));
656
657 let dry = check_reload(&state).expect_err("dry-run rejects repath");
658 assert!(matches!(dry, ReloadError::RoutingShapeChanged(_)));
659 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
660
661 let real = reload_generation(&state).expect_err("real reload rejects repath");
662 assert!(matches!(real, ReloadError::RoutingShapeChanged(_)));
663 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
664 }
665
666 /// Concurrent reload triggers (SIGHUP + admin RPC in production; two threads
667 /// here) are serialized by the reload lock: both apply, generation ids stay
668 /// monotonic with no duplicate stamp, and no candidate is lost.
669 #[test]
670 fn concurrent_reloads_are_serialized_with_monotonic_generations() {
671 let (state, inputs) = state_with_files(&catalog_json(false), &policy_json(false));
672 write_files(&inputs, &catalog_json(true), &policy_json(true));
673
674 let outcomes = std::thread::scope(|scope| {
675 // Spawn both BEFORE joining either, so the two reloads genuinely
676 // overlap (a lazy spawn-then-join iterator would serialize them).
677 let first = scope.spawn(|| reload_generation(&state));
678 let second = scope.spawn(|| reload_generation(&state));
679 [first, second].map(|h| h.join().expect("reload thread panicked"))
680 });
681
682 let mut transitions: Vec<(u64, u64)> = outcomes
683 .into_iter()
684 .map(|o| {
685 let o = o.expect("both concurrent reloads apply");
686 (o.previous_generation, o.new_generation)
687 })
688 .collect();
689 transitions.sort_unstable();
690 // Strictly ordered handoff: N→N+1 then N+1→N+2, never two identical
691 // N→N+1 stamps (the lost-update signature).
692 assert_eq!(
693 transitions,
694 vec![
695 (INITIAL_GENERATION_ID, INITIAL_GENERATION_ID + 1),
696 (INITIAL_GENERATION_ID + 1, INITIAL_GENERATION_ID + 2),
697 ]
698 );
699 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID + 2);
700 }
701
702 /// A broker with no configured paths fails the reload closed (no-op), never
703 /// reading catalog/policy from an unconfigured source.
704 #[test]
705 fn reload_without_inputs_fails_closed() {
706 let (cat, pol, cfg, _) =
707 load(&catalog_json(true), &policy_json(true)).expect("fixture loads");
708 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
709 backends.insert("bao".into(), Box::new(NoopBackend));
710 let manager = BackendManager::new(cat.clone(), backends).expect("manager builds");
711 let state = BrokerState::new(cat, pol, cfg, manager, "noop");
712
713 let err = reload_generation(&state).expect_err("no inputs → fail closed");
714 assert!(matches!(err, ReloadError::NoInputs));
715 assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
716 }
717}