workload_spec/secrets.rs
1//! Pluggable secret resolver for [`crate::SecretRef`] values, plus the access
2//! rule that decides which workloads a cluster secret may be served to.
3//!
4//! The trait lives in `workload-spec` so consumers can construct specs and
5//! invoke the resolver without linking yubaba's containerd client. Yubaba
6//! provides the production impl in `crates/yah/yubaba/src/secrets.rs`.
7//!
8//! ## Access rules (R706 / W294)
9//!
10//! Before R706, `SecretRef::Cluster { name }` was a **bearer reference**:
11//! naming the secret was the entire authorization. [`SecretAccess`] closes
12//! that — it rides on the stored record, so the check happens on the node at
13//! mount time, where it cannot be routed around by a hand-rolled deploy.
14//!
15//! The vocabulary is [`WorkloadSpec`](crate::WorkloadSpec) fields
16//! ([`SecretConsumer`]) rather than, say, cheers principals, because those are
17//! the only identity the enforcement point actually holds: at mount time yubaba
18//! has a `WorkloadSpec` and nothing else.
19//!
20//! Fail-closed by construction: [`SecretAccess::default`] is an **empty**
21//! allow-list, which admits nobody. A legacy record written before this field
22//! existed deserializes to that default, so it is refused rather than granted.
23
24use std::path::PathBuf;
25
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29use crate::{NamespaceId, SecretRef, TenantId, WorkloadSpec};
30
31/// Errors returned by [`SecretResolver::resolve`].
32#[derive(Debug, Error)]
33pub enum SecretError {
34 /// The referenced secret file does not exist in the yubaba secret store.
35 #[error("secret not found at {path}")]
36 NotFound { path: PathBuf },
37
38 /// `SecretRef::Cluster` reached a resolver that has no cluster backing —
39 /// e.g. the per-machine `LocalFileResolver`, which cannot decrypt cluster
40 /// secrets. The fleet resolver (yubaba's `ClusterResolver`) handles the
41 /// `Cluster` arm; this error means the wrong resolver was used.
42 #[error("cluster secrets require a cluster-backed resolver")]
43 ClusterNotImplemented,
44
45 /// The referenced cluster secret is not present in the local raft replica
46 /// (never written, or deleted). Fails closed — nothing is served.
47 #[error("cluster secret {name} not found in the local raft replica")]
48 ClusterNotFound { name: String },
49
50 /// The cluster secret exists but its [`SecretAccess`] rule does not admit
51 /// the requesting workload (R706 / W294).
52 ///
53 /// **The `#[error(...)]` text is a deliberate byte-for-byte duplicate of
54 /// [`SecretError::ClusterNotFound`]'s.** Yubaba surfaces the `Display` form
55 /// of this error in the deploy rejection body, so a distinguishable message
56 /// would turn any workload spec into an oracle for the cluster's secret
57 /// namespace: deploy a throwaway spec naming a guessed secret and read off
58 /// "forbidden" (it exists) versus "not found" (it doesn't). The variants
59 /// stay separate *internally* — the node logs which one it was, and
60 /// `secrets_forbidden_is_externally_indistinguishable` pins the equality so
61 /// a future edit to either message can't silently reopen the oracle.
62 #[error("cluster secret {name} not found in the local raft replica")]
63 Forbidden { name: String },
64
65 /// Decryption or authentication of a cluster secret failed — a wrong
66 /// node-local KEK, a truncated/tampered record, or a malformed nonce. Fails
67 /// closed; the message carries only the logical name, never key or
68 /// ciphertext bytes.
69 #[error("cluster secret {name} failed to decrypt")]
70 ClusterDecrypt { name: String },
71
72 /// The node-local cluster KEK could not be loaded (missing, unreadable, or
73 /// not exactly 32 bytes). Fails closed; `reason` is a generic diagnostic
74 /// and never contains key material.
75 #[error("cluster KEK unavailable: {reason}")]
76 Kek { reason: String },
77
78 /// I/O error reading the secret file.
79 #[error("I/O error reading {path}: {source}")]
80 Io {
81 path: PathBuf,
82 #[source]
83 source: std::io::Error,
84 },
85}
86
87/// Resolves a [`SecretRef`] to its raw byte content.
88///
89/// The trait is defined here (in `workload-spec`) so callers don't need to
90/// link yubaba. Yubaba's `LocalFileResolver` reads from the per-machine secret
91/// store at `/var/lib/yah/yubaba/secrets/`. Tests use an inline `FakeResolver`.
92pub trait SecretResolver {
93 fn resolve(&self, r: &SecretRef) -> Result<Vec<u8>, SecretError>;
94}
95
96// ── Access rules (R706 / W294) ────────────────────────────────────────────────
97
98/// The identity a cluster-secret access rule is evaluated against: the
99/// requesting workload, as yubaba knows it at mount time.
100///
101/// Built from a [`WorkloadSpec`] via [`SecretConsumer::of`]. These three fields
102/// are the whole vocabulary because they are the whole identity available at the
103/// enforcement point — yubaba resolves secrets while holding a spec, with no
104/// cheers principal and no spec→principal mapping in reach.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
107pub struct SecretConsumer {
108 /// [`WorkloadSpec::name`] — the DNS-friendly workload name.
109 pub workload: String,
110 /// [`WorkloadSpec::tenant`] — the isolation axis (W206).
111 pub tenant: TenantId,
112 /// [`WorkloadSpec::namespace`] — the routing/naming axis (W206).
113 pub namespace: NamespaceId,
114 /// The signed recipe this run was admitted as, when it carried a grant that
115 /// **verified** (R555-F5). `None` for every ordinary service workload, and
116 /// for any spec whose grant did not verify — see [`RecipeIdentity`].
117 #[serde(default)]
118 pub recipe: Option<RecipeIdentity>,
119}
120
121/// Who a remote run proved itself to be, cryptographically.
122///
123/// A forge workload's [`WorkloadSpec::name`] is a fresh `forge-<uuid>` per run,
124/// so it can never appear in an allow-list written in advance — which left
125/// [`SecretAccess::AllowAny`] as the only rule under which a dispatched recipe
126/// could read a cluster secret at all. That is precisely the ambient grant W235
127/// §(c) says must not be how a remote build gets the R2 and cosign keys.
128///
129/// This is the durable identity underneath the ephemeral one: the recipe name
130/// out of a verified admission grant, plus the key that vouched for it. Both
131/// halves matter — the name alone would let anyone holding *any* trusted key
132/// mint a grant claiming to be `rusty-v8-musl`.
133///
134/// Construct only from
135/// [`admission::admit_grant`](crate::admission::admit_grant)'s return value.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
138pub struct RecipeIdentity {
139 /// `AdmissionGrant::recipe` from the verified grant.
140 pub recipe: String,
141 /// Hex Ed25519 public key that signed it, as pinned on the node.
142 pub key: String,
143}
144
145impl SecretConsumer {
146 /// The consumer identity of `spec`.
147 ///
148 /// Carries no recipe identity: this constructor sees only the spec, and a
149 /// recipe identity is a claim about a signature. Add one with
150 /// [`SecretConsumer::admitted_as`] after verifying.
151 pub fn of(spec: &WorkloadSpec) -> Self {
152 Self {
153 workload: spec.name.clone(),
154 tenant: spec.tenant.clone(),
155 namespace: spec.namespace.clone(),
156 recipe: None,
157 }
158 }
159
160 /// Attach the recipe identity a verified admission grant established.
161 pub fn admitted_as(mut self, recipe: RecipeIdentity) -> Self {
162 self.recipe = Some(recipe);
163 self
164 }
165
166 /// A consumer in the singleton tenant/namespace — the shape every spec on a
167 /// single-tenant fleet has. Convenience for tests and for authoring rules.
168 pub fn workload(name: impl Into<String>) -> Self {
169 Self {
170 workload: name.into(),
171 tenant: TenantId::singleton(),
172 namespace: NamespaceId::singleton(),
173 recipe: None,
174 }
175 }
176}
177
178/// One entry in a [`SecretAccess::Workloads`] allow-list.
179///
180/// A match requires **all three** fields to be equal. `tenant` and `namespace`
181/// default to their singletons rather than to a wildcard: on today's
182/// single-tenant fleet that makes them free to omit, and it means a rule written
183/// today cannot silently widen to admit a same-named workload in a tenant that
184/// gets created tomorrow. Cross-tenant sharing is spelled as two entries.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
187pub struct WorkloadMatch {
188 /// The admitted [`WorkloadSpec::name`].
189 pub workload: String,
190 /// Tenant the workload must be in. Defaults to [`TenantId::singleton`].
191 #[serde(default = "TenantId::singleton")]
192 pub tenant: TenantId,
193 /// Namespace the workload must be in. Defaults to [`NamespaceId::singleton`].
194 #[serde(default = "NamespaceId::singleton")]
195 pub namespace: NamespaceId,
196}
197
198impl WorkloadMatch {
199 /// A match on `name` in the singleton tenant/namespace.
200 pub fn workload(name: impl Into<String>) -> Self {
201 Self {
202 workload: name.into(),
203 tenant: TenantId::singleton(),
204 namespace: NamespaceId::singleton(),
205 }
206 }
207
208 /// Whether `consumer` satisfies this entry.
209 pub fn admits(&self, consumer: &SecretConsumer) -> bool {
210 self.workload == consumer.workload
211 && self.tenant == consumer.tenant
212 && self.namespace == consumer.namespace
213 }
214}
215
216/// Who may be served a given cluster secret.
217///
218/// Stored alongside the ciphertext (yubaba's `SecretRecord`) so the check rides
219/// on the record itself and is evaluated on the node at mount time — a rule
220/// checked only by the tool that authors a deploy is a lint, not a rule.
221///
222/// [`Default`] is `Workloads(vec![])`, which admits nobody. That is what makes
223/// the migration fail closed: a record serialized before this field existed
224/// deserializes (via `#[serde(default)]`) to an empty allow-list and is refused,
225/// rather than being implicitly granted to everyone.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
228#[serde(rename_all = "snake_case")]
229pub enum SecretAccess {
230 /// Deliberately unrestricted: any workload that names this secret gets it.
231 ///
232 /// This is the *explicit* escape hatch, never an implicit one. It has to be
233 /// written into the record by whoever put the secret there, and it shows up
234 /// in `yah cloud secret ls` as `allow-any`, so an unrestricted secret is an
235 /// auditable choice rather than the silent default.
236 AllowAny,
237
238 /// Only workloads matching one of these entries. An empty list admits
239 /// nobody — see the type-level note on fail-closed defaulting.
240 Workloads(Vec<WorkloadMatch>),
241
242 /// Only runs of one of these **signed recipes** (R555-F5 / W235 §(c)).
243 ///
244 /// The rule a dispatched build needs: its workload name is a per-run
245 /// `forge-<uuid>` that no allow-list can name in advance, so
246 /// [`SecretAccess::Workloads`] cannot express "the rusty-v8-musl build may
247 /// read the R2 write key" and [`SecretAccess::AllowAny`] over-answers it by
248 /// handing that key to anything that can reach the node.
249 ///
250 /// Matching consumes a [`RecipeIdentity`] that only exists on the far side
251 /// of a verified Ed25519 grant, so this is *narrower* than the workload
252 /// rule, not a loophole in it: the requester has to be running argv the
253 /// recipe author signed, on a node that pins the author's key.
254 Recipes(Vec<RecipeMatch>),
255}
256
257/// One entry in a [`SecretAccess::Recipes`] allow-list.
258///
259/// Both fields are required and both are compared exactly. `key` is here
260/// because the recipe *name* is chosen by whoever writes the recipe: without
261/// it, any holder of any key the node trusts could sign a recipe called
262/// `rusty-v8-musl` and inherit its credentials.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
265pub struct RecipeMatch {
266 /// The admitted recipe name, as it appears in the signed grant.
267 pub recipe: String,
268 /// Hex Ed25519 public key that must have signed the grant.
269 pub key: String,
270}
271
272impl RecipeMatch {
273 /// Whether `consumer` presents a verified identity this entry admits.
274 pub fn admits(&self, consumer: &SecretConsumer) -> bool {
275 consumer
276 .recipe
277 .as_ref()
278 .is_some_and(|id| id.recipe == self.recipe && id.key == self.key)
279 }
280}
281
282impl Default for SecretAccess {
283 fn default() -> Self {
284 Self::Workloads(Vec::new())
285 }
286}
287
288impl SecretAccess {
289 /// Allow exactly the named workloads, in the singleton tenant/namespace.
290 pub fn workloads<I, S>(names: I) -> Self
291 where
292 I: IntoIterator<Item = S>,
293 S: Into<String>,
294 {
295 Self::Workloads(names.into_iter().map(WorkloadMatch::workload).collect())
296 }
297
298 /// Whether `consumer` may be served the secret this rule guards.
299 /// Allow exactly the named recipes, each signed by the given hex key.
300 pub fn recipes<I, N, K>(entries: I) -> Self
301 where
302 I: IntoIterator<Item = (N, K)>,
303 N: Into<String>,
304 K: Into<String>,
305 {
306 Self::Recipes(
307 entries
308 .into_iter()
309 .map(|(recipe, key)| RecipeMatch {
310 recipe: recipe.into(),
311 key: key.into(),
312 })
313 .collect(),
314 )
315 }
316
317 pub fn admits(&self, consumer: &SecretConsumer) -> bool {
318 match self {
319 Self::AllowAny => true,
320 Self::Workloads(entries) => entries.iter().any(|e| e.admits(consumer)),
321 Self::Recipes(entries) => entries.iter().any(|e| e.admits(consumer)),
322 }
323 }
324
325 /// Short operator-facing rendering for `yah cloud secret ls`.
326 pub fn summary(&self) -> String {
327 match self {
328 Self::AllowAny => "allow-any".to_string(),
329 Self::Recipes(entries) if entries.is_empty() => "deny-all (no rule)".to_string(),
330 Self::Recipes(entries) => entries
331 .iter()
332 // Keys are 64 hex chars; a truncated prefix is enough to tell
333 // two signing identities apart in a table without wrapping it.
334 .map(|e| format!("recipe {}@{}", e.recipe, &e.key[..e.key.len().min(8)]))
335 .collect::<Vec<_>>()
336 .join(", "),
337 Self::Workloads(entries) if entries.is_empty() => "deny-all (no rule)".to_string(),
338 Self::Workloads(entries) => entries
339 .iter()
340 .map(|e| {
341 if e.tenant.is_singleton() && e.namespace.is_singleton() {
342 e.workload.clone()
343 } else {
344 format!("{}/{}/{}", e.tenant.0, e.namespace.0, e.workload)
345 }
346 })
347 .collect::<Vec<_>>()
348 .join(", "),
349 }
350 }
351}
352
353// ── Sealing (R706 / W294, `seal` feature) ────────────────────────────────────
354
355/// A cluster secret's sealed bytes: AES-256-GCM ciphertext plus the 12-byte
356/// nonce it was sealed under.
357///
358/// Deliberately *not* the storage record — yubaba's `SecretRecord` adds the
359/// timestamp and the access rule and lives in the raft layer. This is only the
360/// cryptographic output, which is the part both writers share.
361#[cfg(feature = "seal")]
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct Sealed {
364 /// AES-256-GCM output: sealed bytes with the GCM tag appended.
365 pub ciphertext: Vec<u8>,
366 /// The 12-byte GCM nonce, freshly drawn for this call.
367 pub nonce: Vec<u8>,
368}
369
370/// AES-256-GCM-seal `plaintext` under the 32-byte cluster `kek`.
371///
372/// A cryptographically-random 12-byte nonce is drawn **per call**, so re-sealing
373/// identical plaintext (a rotation, a re-ship of an unchanged value) never
374/// reuses a nonce. That is the whole reason this lives in one place: nonce reuse
375/// under a fixed key is catastrophic for GCM, and it is exactly the invariant
376/// that erodes when two call sites each roll their own seal.
377///
378/// Infallible by construction: the only error `aead` can return here is a
379/// plaintext-length overflow far beyond any credential.
380#[cfg(feature = "seal")]
381pub fn seal(kek: &[u8; 32], plaintext: &[u8]) -> Sealed {
382 use aes_gcm::aead::{Aead, AeadCore, OsRng};
383 use aes_gcm::{Aes256Gcm, Key, KeyInit};
384
385 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(kek));
386 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
387 let ciphertext = cipher
388 .encrypt(&nonce, plaintext)
389 .expect("AES-256-GCM seal of a KB-scale secret cannot fail on length");
390 Sealed {
391 ciphertext,
392 nonce: nonce.to_vec(),
393 }
394}
395
396/// Draw 32 cryptographically-secure random bytes for a fresh cluster KEK.
397///
398/// Same `OsRng` [`seal`] draws its nonces from, on purpose: a KEK minted from a
399/// weaker source would silently undermine every secret sealed under it, and
400/// pulling a second RNG dependency into the camp is how that happens.
401#[cfg(feature = "seal")]
402pub fn generate_kek() -> zeroize::Zeroizing<[u8; 32]> {
403 use aes_gcm::aead::rand_core::RngCore;
404 let mut kek = zeroize::Zeroizing::new([0u8; 32]);
405 aes_gcm::aead::OsRng.fill_bytes(kek.as_mut());
406 kek
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[cfg(feature = "seal")]
414 #[test]
415 fn seal_draws_a_fresh_nonce_per_call() {
416 let kek = [7u8; 32];
417 let a = seal(&kek, b"same-plaintext");
418 let b = seal(&kek, b"same-plaintext");
419 assert_eq!(a.nonce.len(), 12);
420 assert_ne!(a.nonce, b.nonce, "nonce must never repeat under one key");
421 assert_ne!(a.ciphertext, b.ciphertext);
422 assert_ne!(a.ciphertext, b"same-plaintext".to_vec());
423 }
424
425 #[cfg(feature = "seal")]
426 #[test]
427 fn generated_keks_are_32_bytes_and_distinct() {
428 let a = generate_kek();
429 let b = generate_kek();
430 assert_eq!(a.len(), 32);
431 assert_ne!(*a, *b, "two mints must not collide");
432 assert_ne!(*a, [0u8; 32], "must not be all-zero");
433 }
434
435 #[test]
436 fn default_access_admits_nobody() {
437 // The fail-closed migration hinges on exactly this.
438 let rule = SecretAccess::default();
439 assert!(!rule.admits(&SecretConsumer::workload("yah-cloud-admin")));
440 assert_eq!(rule.summary(), "deny-all (no rule)");
441 }
442
443 #[test]
444 fn legacy_record_shape_deserializes_to_deny_all() {
445 // A record serialized before the field existed: serde(default) must land
446 // on deny-all, not allow-all.
447 #[derive(Deserialize)]
448 struct Legacyish {
449 #[serde(default)]
450 access: SecretAccess,
451 }
452 let v: Legacyish = serde_json::from_str("{}").unwrap();
453 assert!(!v.access.admits(&SecretConsumer::workload("anything")));
454 }
455
456 #[test]
457 fn allow_list_matches_on_all_three_axes() {
458 let rule = SecretAccess::workloads(["yah-cloud-admin"]);
459 assert!(rule.admits(&SecretConsumer::workload("yah-cloud-admin")));
460 assert!(!rule.admits(&SecretConsumer::workload("other-service")));
461
462 // Same name, different tenant → refused (the entry defaulted to the
463 // singleton tenant, and defaults are narrowing, not widening).
464 let other_tenant = SecretConsumer {
465 workload: "yah-cloud-admin".into(),
466 tenant: TenantId("acme".into()),
467 namespace: NamespaceId::singleton(),
468 recipe: None,
469 };
470 assert!(!rule.admits(&other_tenant));
471 }
472
473 #[test]
474 fn allow_any_is_explicit_and_visible() {
475 let rule = SecretAccess::AllowAny;
476 assert!(rule.admits(&SecretConsumer::workload("anything-at-all")));
477 assert_eq!(rule.summary(), "allow-any");
478 // And it must survive a round-trip as a distinct, greppable token.
479 let json = serde_json::to_string(&rule).unwrap();
480 assert_eq!(json, "\"allow_any\"");
481 }
482
483 #[test]
484 fn omitted_tenant_and_namespace_default_to_singleton() {
485 let m: WorkloadMatch = serde_json::from_str(r#"{"workload":"api"}"#).unwrap();
486 assert_eq!(m.tenant, TenantId::singleton());
487 assert_eq!(m.namespace, NamespaceId::singleton());
488 }
489
490 // ── recipe rules (R555-F5) ───────────────────────────────────────────────
491
492 const KEY: &str = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0537bb43f2a8d9c";
493
494 fn forge_run(recipe: Option<&str>) -> SecretConsumer {
495 // What a dispatched build actually looks like: a per-run workload name
496 // no allow-list could have named in advance.
497 let c = SecretConsumer::workload("forge-0193a7c2-9f11-7e3a-9c1e-2b0f4d8e6a55");
498 match recipe {
499 Some(r) => c.admitted_as(RecipeIdentity {
500 recipe: r.into(),
501 key: KEY.into(),
502 }),
503 None => c,
504 }
505 }
506
507 #[test]
508 fn a_recipe_rule_admits_the_signed_recipe_whatever_the_run_is_called() {
509 let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
510 assert!(rule.admits(&forge_run(Some("rusty-v8-musl"))));
511 // A second run of the same recipe has a different workload name and is
512 // still admitted — that is the whole point of keying on the recipe.
513 let other_run = SecretConsumer::workload("forge-0193a7c2-ffff-7e3a-9c1e-2b0f4d8e6a55")
514 .admitted_as(RecipeIdentity {
515 recipe: "rusty-v8-musl".into(),
516 key: KEY.into(),
517 });
518 assert!(rule.admits(&other_run));
519 }
520
521 #[test]
522 fn a_recipe_rule_admits_nobody_without_a_verified_identity() {
523 // The fail-closed direction: an unsigned dispatch, or one whose grant
524 // did not verify, carries `recipe: None` and gets nothing.
525 let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
526 assert!(!rule.admits(&forge_run(None)));
527 assert!(!rule.admits(&SecretConsumer::workload("rusty-v8-musl")));
528 }
529
530 #[test]
531 fn a_recipe_rule_matches_on_the_signing_key_too() {
532 // Otherwise anyone holding any key the node pins could sign a recipe
533 // named `rusty-v8-musl` and inherit its credentials.
534 let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
535 let impostor = SecretConsumer::workload("forge-1").admitted_as(RecipeIdentity {
536 recipe: "rusty-v8-musl".into(),
537 key: "00".repeat(32),
538 });
539 assert!(!rule.admits(&impostor));
540 assert!(!rule.admits(&forge_run(Some("whisper-bundle-tar"))));
541 }
542
543 #[test]
544 fn the_two_rule_kinds_do_not_leak_into_each_other() {
545 // A workload rule is not satisfied by a recipe identity...
546 let by_workload = SecretAccess::workloads(["rusty-v8-musl"]);
547 assert!(!by_workload.admits(&forge_run(Some("rusty-v8-musl"))));
548 // ...and a recipe rule is not satisfied by a same-named workload.
549 let by_recipe = SecretAccess::recipes([("ingress", KEY)]);
550 assert!(!by_recipe.admits(&SecretConsumer::workload("ingress")));
551 }
552
553 #[test]
554 fn an_empty_recipe_list_admits_nobody_and_says_so() {
555 let rule = SecretAccess::Recipes(Vec::new());
556 assert!(!rule.admits(&forge_run(Some("rusty-v8-musl"))));
557 assert_eq!(rule.summary(), "deny-all (no rule)");
558 }
559
560 #[test]
561 fn a_recipe_rule_renders_recipe_and_key_prefix() {
562 let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
563 assert_eq!(rule.summary(), "recipe rusty-v8-musl@3d4017c3");
564 }
565
566 #[test]
567 fn a_recipe_rule_round_trips_through_the_stored_record() {
568 let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
569 let json = serde_json::to_string(&rule).unwrap();
570 assert_eq!(serde_json::from_str::<SecretAccess>(&json).unwrap(), rule);
571 // And the pre-R555-F5 record shape still deserializes unchanged.
572 let legacy: SecretAccess =
573 serde_json::from_str(r#"{"workloads":[{"workload":"ingress"}]}"#).unwrap();
574 assert!(legacy.admits(&SecretConsumer::workload("ingress")));
575 }
576
577 #[test]
578 fn a_consumer_serialized_before_this_field_existed_carries_no_recipe() {
579 // `recipe` is serde(default) on SecretConsumer, and the default is None
580 // — an absent field must not become a claim.
581 let c: SecretConsumer = serde_json::from_str(
582 r#"{"workload":"ingress","tenant":"default","namespace":"default"}"#,
583 )
584 .unwrap();
585 assert_eq!(c.recipe, None);
586 }
587
588 #[test]
589 fn secrets_forbidden_is_externally_indistinguishable() {
590 // A probing spec must not be able to tell "exists but denied" from
591 // "does not exist" — see the note on SecretError::Forbidden.
592 let denied = SecretError::Forbidden {
593 name: "cheers/cloud-admin/verify-key".into(),
594 };
595 let absent = SecretError::ClusterNotFound {
596 name: "cheers/cloud-admin/verify-key".into(),
597 };
598 assert_eq!(denied.to_string(), absent.to_string());
599 }
600}