Skip to main content

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}