use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{NamespaceId, SecretRef, TenantId, WorkloadSpec};
#[derive(Debug, Error)]
pub enum SecretError {
#[error("secret not found at {path}")]
NotFound { path: PathBuf },
#[error("cluster secrets require a cluster-backed resolver")]
ClusterNotImplemented,
#[error("cluster secret {name} not found in the local raft replica")]
ClusterNotFound { name: String },
#[error("cluster secret {name} not found in the local raft replica")]
Forbidden { name: String },
#[error("cluster secret {name} failed to decrypt")]
ClusterDecrypt { name: String },
#[error("cluster KEK unavailable: {reason}")]
Kek { reason: String },
#[error("I/O error reading {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub trait SecretResolver {
fn resolve(&self, r: &SecretRef) -> Result<Vec<u8>, SecretError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SecretConsumer {
pub workload: String,
pub tenant: TenantId,
pub namespace: NamespaceId,
#[serde(default)]
pub recipe: Option<RecipeIdentity>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct RecipeIdentity {
pub recipe: String,
pub key: String,
}
impl SecretConsumer {
pub fn of(spec: &WorkloadSpec) -> Self {
Self {
workload: spec.name.clone(),
tenant: spec.tenant.clone(),
namespace: spec.namespace.clone(),
recipe: None,
}
}
pub fn admitted_as(mut self, recipe: RecipeIdentity) -> Self {
self.recipe = Some(recipe);
self
}
pub fn workload(name: impl Into<String>) -> Self {
Self {
workload: name.into(),
tenant: TenantId::singleton(),
namespace: NamespaceId::singleton(),
recipe: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct WorkloadMatch {
pub workload: String,
#[serde(default = "TenantId::singleton")]
pub tenant: TenantId,
#[serde(default = "NamespaceId::singleton")]
pub namespace: NamespaceId,
}
impl WorkloadMatch {
pub fn workload(name: impl Into<String>) -> Self {
Self {
workload: name.into(),
tenant: TenantId::singleton(),
namespace: NamespaceId::singleton(),
}
}
pub fn admits(&self, consumer: &SecretConsumer) -> bool {
self.workload == consumer.workload
&& self.tenant == consumer.tenant
&& self.namespace == consumer.namespace
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SecretAccess {
AllowAny,
Workloads(Vec<WorkloadMatch>),
Recipes(Vec<RecipeMatch>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct RecipeMatch {
pub recipe: String,
pub key: String,
}
impl RecipeMatch {
pub fn admits(&self, consumer: &SecretConsumer) -> bool {
consumer
.recipe
.as_ref()
.is_some_and(|id| id.recipe == self.recipe && id.key == self.key)
}
}
impl Default for SecretAccess {
fn default() -> Self {
Self::Workloads(Vec::new())
}
}
impl SecretAccess {
pub fn workloads<I, S>(names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::Workloads(names.into_iter().map(WorkloadMatch::workload).collect())
}
pub fn recipes<I, N, K>(entries: I) -> Self
where
I: IntoIterator<Item = (N, K)>,
N: Into<String>,
K: Into<String>,
{
Self::Recipes(
entries
.into_iter()
.map(|(recipe, key)| RecipeMatch {
recipe: recipe.into(),
key: key.into(),
})
.collect(),
)
}
pub fn admits(&self, consumer: &SecretConsumer) -> bool {
match self {
Self::AllowAny => true,
Self::Workloads(entries) => entries.iter().any(|e| e.admits(consumer)),
Self::Recipes(entries) => entries.iter().any(|e| e.admits(consumer)),
}
}
pub fn summary(&self) -> String {
match self {
Self::AllowAny => "allow-any".to_string(),
Self::Recipes(entries) if entries.is_empty() => "deny-all (no rule)".to_string(),
Self::Recipes(entries) => entries
.iter()
.map(|e| format!("recipe {}@{}", e.recipe, &e.key[..e.key.len().min(8)]))
.collect::<Vec<_>>()
.join(", "),
Self::Workloads(entries) if entries.is_empty() => "deny-all (no rule)".to_string(),
Self::Workloads(entries) => entries
.iter()
.map(|e| {
if e.tenant.is_singleton() && e.namespace.is_singleton() {
e.workload.clone()
} else {
format!("{}/{}/{}", e.tenant.0, e.namespace.0, e.workload)
}
})
.collect::<Vec<_>>()
.join(", "),
}
}
}
#[cfg(feature = "seal")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sealed {
pub ciphertext: Vec<u8>,
pub nonce: Vec<u8>,
}
#[cfg(feature = "seal")]
pub fn seal(kek: &[u8; 32], plaintext: &[u8]) -> Sealed {
use aes_gcm::aead::{Aead, AeadCore, OsRng};
use aes_gcm::{Aes256Gcm, Key, KeyInit};
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(kek));
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.expect("AES-256-GCM seal of a KB-scale secret cannot fail on length");
Sealed {
ciphertext,
nonce: nonce.to_vec(),
}
}
#[cfg(feature = "seal")]
pub fn generate_kek() -> zeroize::Zeroizing<[u8; 32]> {
use aes_gcm::aead::rand_core::RngCore;
let mut kek = zeroize::Zeroizing::new([0u8; 32]);
aes_gcm::aead::OsRng.fill_bytes(kek.as_mut());
kek
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "seal")]
#[test]
fn seal_draws_a_fresh_nonce_per_call() {
let kek = [7u8; 32];
let a = seal(&kek, b"same-plaintext");
let b = seal(&kek, b"same-plaintext");
assert_eq!(a.nonce.len(), 12);
assert_ne!(a.nonce, b.nonce, "nonce must never repeat under one key");
assert_ne!(a.ciphertext, b.ciphertext);
assert_ne!(a.ciphertext, b"same-plaintext".to_vec());
}
#[cfg(feature = "seal")]
#[test]
fn generated_keks_are_32_bytes_and_distinct() {
let a = generate_kek();
let b = generate_kek();
assert_eq!(a.len(), 32);
assert_ne!(*a, *b, "two mints must not collide");
assert_ne!(*a, [0u8; 32], "must not be all-zero");
}
#[test]
fn default_access_admits_nobody() {
let rule = SecretAccess::default();
assert!(!rule.admits(&SecretConsumer::workload("yah-cloud-admin")));
assert_eq!(rule.summary(), "deny-all (no rule)");
}
#[test]
fn legacy_record_shape_deserializes_to_deny_all() {
#[derive(Deserialize)]
struct Legacyish {
#[serde(default)]
access: SecretAccess,
}
let v: Legacyish = serde_json::from_str("{}").unwrap();
assert!(!v.access.admits(&SecretConsumer::workload("anything")));
}
#[test]
fn allow_list_matches_on_all_three_axes() {
let rule = SecretAccess::workloads(["yah-cloud-admin"]);
assert!(rule.admits(&SecretConsumer::workload("yah-cloud-admin")));
assert!(!rule.admits(&SecretConsumer::workload("other-service")));
let other_tenant = SecretConsumer {
workload: "yah-cloud-admin".into(),
tenant: TenantId("acme".into()),
namespace: NamespaceId::singleton(),
recipe: None,
};
assert!(!rule.admits(&other_tenant));
}
#[test]
fn allow_any_is_explicit_and_visible() {
let rule = SecretAccess::AllowAny;
assert!(rule.admits(&SecretConsumer::workload("anything-at-all")));
assert_eq!(rule.summary(), "allow-any");
let json = serde_json::to_string(&rule).unwrap();
assert_eq!(json, "\"allow_any\"");
}
#[test]
fn omitted_tenant_and_namespace_default_to_singleton() {
let m: WorkloadMatch = serde_json::from_str(r#"{"workload":"api"}"#).unwrap();
assert_eq!(m.tenant, TenantId::singleton());
assert_eq!(m.namespace, NamespaceId::singleton());
}
const KEY: &str = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0537bb43f2a8d9c";
fn forge_run(recipe: Option<&str>) -> SecretConsumer {
let c = SecretConsumer::workload("forge-0193a7c2-9f11-7e3a-9c1e-2b0f4d8e6a55");
match recipe {
Some(r) => c.admitted_as(RecipeIdentity {
recipe: r.into(),
key: KEY.into(),
}),
None => c,
}
}
#[test]
fn a_recipe_rule_admits_the_signed_recipe_whatever_the_run_is_called() {
let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
assert!(rule.admits(&forge_run(Some("rusty-v8-musl"))));
let other_run = SecretConsumer::workload("forge-0193a7c2-ffff-7e3a-9c1e-2b0f4d8e6a55")
.admitted_as(RecipeIdentity {
recipe: "rusty-v8-musl".into(),
key: KEY.into(),
});
assert!(rule.admits(&other_run));
}
#[test]
fn a_recipe_rule_admits_nobody_without_a_verified_identity() {
let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
assert!(!rule.admits(&forge_run(None)));
assert!(!rule.admits(&SecretConsumer::workload("rusty-v8-musl")));
}
#[test]
fn a_recipe_rule_matches_on_the_signing_key_too() {
let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
let impostor = SecretConsumer::workload("forge-1").admitted_as(RecipeIdentity {
recipe: "rusty-v8-musl".into(),
key: "00".repeat(32),
});
assert!(!rule.admits(&impostor));
assert!(!rule.admits(&forge_run(Some("whisper-bundle-tar"))));
}
#[test]
fn the_two_rule_kinds_do_not_leak_into_each_other() {
let by_workload = SecretAccess::workloads(["rusty-v8-musl"]);
assert!(!by_workload.admits(&forge_run(Some("rusty-v8-musl"))));
let by_recipe = SecretAccess::recipes([("ingress", KEY)]);
assert!(!by_recipe.admits(&SecretConsumer::workload("ingress")));
}
#[test]
fn an_empty_recipe_list_admits_nobody_and_says_so() {
let rule = SecretAccess::Recipes(Vec::new());
assert!(!rule.admits(&forge_run(Some("rusty-v8-musl"))));
assert_eq!(rule.summary(), "deny-all (no rule)");
}
#[test]
fn a_recipe_rule_renders_recipe_and_key_prefix() {
let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
assert_eq!(rule.summary(), "recipe rusty-v8-musl@3d4017c3");
}
#[test]
fn a_recipe_rule_round_trips_through_the_stored_record() {
let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
let json = serde_json::to_string(&rule).unwrap();
assert_eq!(serde_json::from_str::<SecretAccess>(&json).unwrap(), rule);
let legacy: SecretAccess =
serde_json::from_str(r#"{"workloads":[{"workload":"ingress"}]}"#).unwrap();
assert!(legacy.admits(&SecretConsumer::workload("ingress")));
}
#[test]
fn a_consumer_serialized_before_this_field_existed_carries_no_recipe() {
let c: SecretConsumer = serde_json::from_str(
r#"{"workload":"ingress","tenant":"default","namespace":"default"}"#,
)
.unwrap();
assert_eq!(c.recipe, None);
}
#[test]
fn secrets_forbidden_is_externally_indistinguishable() {
let denied = SecretError::Forbidden {
name: "cheers/cloud-admin/verify-key".into(),
};
let absent = SecretError::ClusterNotFound {
name: "cheers/cloud-admin/verify-key".into(),
};
assert_eq!(denied.to_string(), absent.to_string());
}
}