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