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,
}
impl SecretConsumer {
pub fn of(spec: &WorkloadSpec) -> Self {
Self {
workload: spec.name.clone(),
tenant: spec.tenant.clone(),
namespace: spec.namespace.clone(),
}
}
pub fn workload(name: impl Into<String>) -> Self {
Self {
workload: name.into(),
tenant: TenantId::singleton(),
namespace: NamespaceId::singleton(),
}
}
}
#[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>),
}
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 admits(&self, consumer: &SecretConsumer) -> bool {
match self {
Self::AllowAny => true,
Self::Workloads(entries) => entries.iter().any(|e| e.admits(consumer)),
}
}
pub fn summary(&self) -> String {
match self {
Self::AllowAny => "allow-any".to_string(),
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(),
};
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());
}
#[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());
}
}