use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
use crate::error::SkiljRejection;
use crate::event_store::BoundedContext;
use crate::shared::generate_token_id;
pub const SUBJECT_KEY: &str = "secret";
pub fn is_reserved_subject_key(subject_key: &str) -> bool {
subject_key == SUBJECT_KEY
}
pub const MAX_NAME_LEN: usize = 256;
pub fn subject_value(name: &str, version: i64) -> String {
format!("{name}#{version}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SecretStatus {
Active,
Rotated,
Revoked,
}
impl SecretStatus {
pub fn as_str(&self) -> &'static str {
match self {
SecretStatus::Active => "active",
SecretStatus::Rotated => "rotated",
SecretStatus::Revoked => "revoked",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Secret {
pub bounded_context: BoundedContext,
pub name: String,
pub version: i64,
pub status: SecretStatus,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub rotated_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
pub last_read_at: Option<DateTime<Utc>>,
pub last_read_by: Option<String>,
}
impl Secret {
pub fn is_active(&self) -> bool {
self.status == SecretStatus::Active
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretGrant {
pub id: String,
pub bounded_context: BoundedContext,
pub name: String,
pub grantee_role_id: String,
pub status: SecretGrantStatus,
pub created_at: DateTime<Utc>,
pub revoked_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SecretGrantStatus {
Active,
Revoked,
}
impl SecretGrantStatus {
pub fn as_str(&self) -> &'static str {
match self {
SecretGrantStatus::Active => "active",
SecretGrantStatus::Revoked => "revoked",
}
}
}
pub struct SecretValue(String);
impl SecretValue {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose(&self) -> &str {
&self.0
}
pub fn into_inner(mut self) -> String {
std::mem::take(&mut self.0)
}
}
impl Drop for SecretValue {
fn drop(&mut self) {
self.0.zeroize();
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error(
"a secret name must be 1-{MAX_NAME_LEN} characters of ASCII letters, digits, '_', '.' or '-'"
)]
InvalidName,
#[error("no secret named \"{name}\" has an active version")]
NotActive {
name: String,
},
#[error("this caller is not granted read access to secret \"{name}\"")]
AccessNotGranted {
name: String,
},
#[error("a secret grant must name the Role it is granted to")]
InvalidGrantee,
#[error("the subject key \"{SUBJECT_KEY}\" is reserved for secrets")]
ReservedSubjectKey,
}
impl SkiljRejection for Error {
fn code(&self) -> &str {
match self {
Error::InvalidName => "secret_name_invalid",
Error::NotActive { .. } => "secret_not_active",
Error::AccessNotGranted { .. } => "secret_access_not_granted",
Error::InvalidGrantee => "secret_grantee_invalid",
Error::ReservedSubjectKey => "subject_key_reserved",
}
}
fn message(&self) -> String {
self.to_string()
}
}
pub fn validate_secret_name(name: &str) -> Result<(), Error> {
if name.is_empty()
|| name.len() > MAX_NAME_LEN
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
{
return Err(Error::InvalidName);
}
Ok(())
}
pub fn rotate_secret(current: &Secret, now: DateTime<Utc>) -> Result<Secret, Error> {
if !current.is_active() {
return Err(Error::NotActive {
name: current.name.clone(),
});
}
let mut rotated = current.clone();
rotated.status = SecretStatus::Rotated;
rotated.rotated_at = Some(now);
Ok(rotated)
}
pub fn revoke_secret(current: &Secret, now: DateTime<Utc>) -> Result<Secret, Error> {
if !current.is_active() {
return Err(Error::NotActive {
name: current.name.clone(),
});
}
let mut revoked = current.clone();
revoked.status = SecretStatus::Revoked;
revoked.revoked_at = Some(now);
Ok(revoked)
}
pub fn grant_secret_access(
bounded_context: &BoundedContext,
name: &str,
grantee_role_id: &str,
now: DateTime<Utc>,
) -> Result<SecretGrant, Error> {
validate_secret_name(name)?;
if grantee_role_id.is_empty() {
return Err(Error::InvalidGrantee);
}
Ok(SecretGrant {
id: generate_token_id(),
bounded_context: bounded_context.clone(),
name: name.to_string(),
grantee_role_id: grantee_role_id.to_string(),
status: SecretGrantStatus::Active,
created_at: now,
revoked_at: None,
})
}
pub fn revoke_secret_access(grant: &SecretGrant, now: DateTime<Utc>) -> SecretGrant {
let mut revoked = grant.clone();
revoked.status = SecretGrantStatus::Revoked;
revoked.revoked_at = Some(now);
revoked
}
pub fn secret_is_readable_by(name: &str, caller_role_id: &str, grants: &[SecretGrant]) -> bool {
grants.iter().any(|g| {
g.status == SecretGrantStatus::Active
&& g.name == name
&& g.grantee_role_id == caller_role_id
})
}
pub fn require_secret_access(
name: &str,
caller_role_id: &str,
grants: &[SecretGrant],
) -> Result<(), Error> {
if secret_is_readable_by(name, caller_role_id, grants) {
Ok(())
} else {
Err(Error::AccessNotGranted {
name: name.to_string(),
})
}
}
pub fn secret_needs_refresh(secret: &Secret, now: DateTime<Utc>, lead_time: Duration) -> bool {
secret.is_active()
&& secret
.expires_at
.is_some_and(|expires| expires.signed_duration_since(now) <= lead_time)
}
#[cfg(test)]
mod unit_tests {
use super::*;
use crate::bootstrap::ContextCreator;
use crate::event_store::BoundedContextStatus;
fn bc() -> BoundedContext {
BoundedContext {
name: "banking".into(),
status: BoundedContextStatus::Active,
created_at: Utc::now(),
created_by: ContextCreator::SystemCreator,
template: None,
}
}
fn active_secret() -> Secret {
Secret {
bounded_context: bc(),
name: "github-oauth-refresh-token".into(),
version: 1,
status: SecretStatus::Active,
expires_at: None,
created_at: Utc::now(),
rotated_at: None,
revoked_at: None,
last_read_at: None,
last_read_by: None,
}
}
#[test]
fn name_validation_accepts_reasonable_names_and_rejects_the_rest() {
assert!(validate_secret_name("github-oauth-refresh-token").is_ok());
assert!(validate_secret_name("a.b_c-1").is_ok());
assert!(validate_secret_name("").is_err());
assert!(validate_secret_name("has#hash").is_err()); assert!(validate_secret_name("has space").is_err());
assert!(validate_secret_name(&"x".repeat(MAX_NAME_LEN + 1)).is_err());
}
#[test]
fn subject_value_cannot_collide_because_names_cannot_contain_hash() {
assert_eq!(subject_value("oauth", 2), "oauth#2");
assert!(validate_secret_name("oauth#2").is_err());
}
#[test]
fn rotate_only_accepts_an_active_version() {
let now = Utc::now();
let rotated = rotate_secret(&active_secret(), now).unwrap();
assert_eq!(rotated.status, SecretStatus::Rotated);
assert_eq!(rotated.rotated_at, Some(now));
assert!(rotate_secret(&rotated, now).is_err());
}
#[test]
fn revoke_only_accepts_an_active_version() {
let now = Utc::now();
let revoked = revoke_secret(&active_secret(), now).unwrap();
assert_eq!(revoked.status, SecretStatus::Revoked);
assert_eq!(revoked.revoked_at, Some(now));
assert!(revoke_secret(&revoked, now).is_err());
}
#[test]
fn a_fresh_adapter_with_no_grant_is_refused() {
let grants = vec![grant_secret_access(&bc(), "oauth", "role-1", Utc::now()).unwrap()];
assert!(!secret_is_readable_by("oauth", "role-2", &grants));
assert_eq!(
require_secret_access("oauth", "role-2", &grants)
.unwrap_err()
.code(),
"secret_access_not_granted"
);
assert!(!secret_is_readable_by("payments", "role-1", &grants));
}
#[test]
fn a_revoked_grant_stops_being_readable() {
let grant = grant_secret_access(&bc(), "oauth", "role-1", Utc::now()).unwrap();
assert!(secret_is_readable_by(
"oauth",
"role-1",
std::slice::from_ref(&grant)
));
let revoked = revoke_secret_access(&grant, Utc::now());
assert!(!secret_is_readable_by("oauth", "role-1", &[revoked]));
}
#[test]
fn refresh_is_due_within_the_lead_time_of_expiry() {
let now = Utc::now();
let lead = Duration::minutes(5);
let mut secret = active_secret();
assert!(!secret_needs_refresh(&secret, now, lead));
secret.expires_at = Some(now + Duration::minutes(6));
assert!(!secret_needs_refresh(&secret, now, lead));
secret.expires_at = Some(now + Duration::minutes(4));
assert!(secret_needs_refresh(&secret, now, lead));
secret.expires_at = Some(now - Duration::seconds(1));
assert!(secret_needs_refresh(&secret, now, lead));
secret.expires_at = Some(now + Duration::seconds(1));
assert!(!secret_needs_refresh(&secret, now, Duration::zero()));
secret.expires_at = Some(now + Duration::days(365));
assert!(secret_needs_refresh(&secret, now, Duration::MAX));
}
#[test]
fn a_rotated_version_never_needs_a_refresh() {
let now = Utc::now();
let mut secret = active_secret();
secret.expires_at = Some(now - Duration::seconds(1));
let rotated = rotate_secret(&secret, now).unwrap();
assert!(!secret_needs_refresh(&rotated, now, Duration::zero()));
}
#[test]
fn an_empty_grantee_is_its_own_error() {
assert_eq!(
grant_secret_access(&bc(), "oauth", "", Utc::now())
.unwrap_err()
.code(),
"secret_grantee_invalid"
);
}
#[test]
fn only_the_secret_namespace_is_reserved() {
assert!(is_reserved_subject_key(SUBJECT_KEY));
assert!(!is_reserved_subject_key("customer"));
assert!(!is_reserved_subject_key("secrets"));
}
#[test]
fn secret_value_exposes_only_through_an_explicit_call() {
let value = SecretValue::new("plaintext-credential");
assert_eq!(value.expose(), "plaintext-credential");
assert_eq!(value.into_inner(), "plaintext-credential");
}
}