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}