use std::fmt;
use std::num::NonZeroU64;
use super::ids::{ProjectId, SecretId, TenantId};
use super::resource::ResourceScope;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SecretVersion(NonZeroU64);
impl SecretVersion {
pub const FIRST: Self = Self(NonZeroU64::MIN);
pub const fn new(version: u64) -> Option<Self> {
match NonZeroU64::new(version) {
Some(version) => Some(Self(version)),
None => None,
}
}
pub const fn get(self) -> u64 {
self.0.get()
}
pub const fn next(self) -> Self {
Self(self.0.saturating_add(1))
}
}
impl fmt::Display for SecretVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "v{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SecretRef {
pub secret: SecretId,
pub version: SecretVersion,
}
impl SecretRef {
pub const fn new(secret: SecretId, version: SecretVersion) -> Self {
Self { secret, version }
}
pub const fn first(secret: SecretId) -> Self {
Self::new(secret, SecretVersion::FIRST)
}
pub const fn rotated(self) -> Self {
Self::new(self.secret, self.version.next())
}
pub fn is_same_secret(self, other: Self) -> bool {
self.secret == other.secret
}
}
impl fmt::Display for SecretRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}@{}", self.secret, self.version)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SecretOwner {
pub tenant: TenantId,
pub project: Option<ProjectId>,
}
impl SecretOwner {
pub const fn tenant(tenant: TenantId) -> Self {
Self {
tenant,
project: None,
}
}
pub const fn project(tenant: TenantId, project: ProjectId) -> Self {
Self {
tenant,
project: Some(project),
}
}
pub const fn from_scope(scope: &ResourceScope) -> Option<Self> {
match scope {
ResourceScope::Deployment => None,
ResourceScope::Tenant(tenant) => Some(Self::tenant(*tenant)),
ResourceScope::Project { tenant, project } => Some(Self::project(*tenant, *project)),
}
}
pub const fn scope(self) -> ResourceScope {
match self.project {
None => ResourceScope::Tenant(self.tenant),
Some(project) => ResourceScope::Project {
tenant: self.tenant,
project,
},
}
}
}
impl fmt::Display for SecretOwner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.project {
None => write!(f, "{}", self.tenant),
Some(project) => write!(f, "{}/{project}", self.tenant),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum SecretLifecycle {
#[default]
Staged,
Active,
Disabled,
Revoked,
Tombstoned,
}
impl SecretLifecycle {
pub const ALL: &'static [Self] = &[
Self::Staged,
Self::Active,
Self::Disabled,
Self::Revoked,
Self::Tombstoned,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Staged => "staged",
Self::Active => "active",
Self::Disabled => "disabled",
Self::Revoked => "revoked",
Self::Tombstoned => "tombstoned",
}
}
pub fn parse(input: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|state| state.as_str() == input)
}
pub const fn permits_resolution(self) -> bool {
matches!(self, Self::Staged | Self::Active)
}
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Tombstoned)
}
pub const fn is_withdrawn(self) -> bool {
matches!(self, Self::Revoked | Self::Tombstoned)
}
pub fn transition_to(self, next: Self) -> Result<LifecycleTransition, ForbiddenTransition> {
if self == next {
return Ok(LifecycleTransition::Unchanged(self));
}
let permitted = match self {
Self::Staged => matches!(next, Self::Active | Self::Disabled | Self::Revoked),
Self::Active | Self::Disabled => {
matches!(next, Self::Active | Self::Disabled | Self::Revoked)
}
Self::Revoked => matches!(next, Self::Tombstoned),
Self::Tombstoned => false,
};
if permitted {
Ok(LifecycleTransition::Moved {
from: self,
to: next,
})
} else {
Err(ForbiddenTransition {
from: self,
to: next,
})
}
}
}
impl fmt::Display for SecretLifecycle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LifecycleTransition {
Unchanged(SecretLifecycle),
Moved {
from: SecretLifecycle,
to: SecretLifecycle,
},
}
impl LifecycleTransition {
pub const fn state(self) -> SecretLifecycle {
match self {
Self::Unchanged(state) => state,
Self::Moved { to, .. } => to,
}
}
pub const fn changed(self) -> bool {
matches!(self, Self::Moved { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("a {from} secret cannot become {to}")]
pub struct ForbiddenTransition {
pub from: SecretLifecycle,
pub to: SecretLifecycle,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::desired_state::fixtures::{project_id, secret_id, tenant_id};
#[test]
fn a_reference_names_an_exact_version_and_prints_no_material() {
let secret = secret_id(1);
let reference = SecretRef::first(secret);
assert_eq!(reference.version, SecretVersion::FIRST);
assert_eq!(reference.to_string(), format!("{secret}@v1"));
let rotated = reference.rotated();
assert_eq!(rotated.version.get(), 2);
assert!(rotated.is_same_secret(reference));
assert_ne!(rotated, reference, "a rotation is a different reference");
assert!(format!("{reference:?}").contains(&secret.uuid().to_string()));
}
#[test]
fn version_zero_is_not_a_version() {
assert_eq!(SecretVersion::new(0), None);
assert_eq!(SecretVersion::new(1), Some(SecretVersion::FIRST));
assert_eq!(SecretVersion::new(7).map(SecretVersion::get), Some(7));
}
#[test]
fn an_owner_is_the_scope_it_came_from() {
let tenant = tenant_id(1);
let project = project_id(2);
assert_eq!(
SecretOwner::from_scope(&ResourceScope::Tenant(tenant)),
Some(SecretOwner::tenant(tenant))
);
assert_eq!(
SecretOwner::from_scope(&ResourceScope::Project { tenant, project }),
Some(SecretOwner::project(tenant, project))
);
assert_eq!(SecretOwner::from_scope(&ResourceScope::Deployment), None);
for owner in [
SecretOwner::tenant(tenant),
SecretOwner::project(tenant, project),
] {
assert_eq!(SecretOwner::from_scope(&owner.scope()), Some(owner));
}
assert_ne!(
SecretOwner::tenant(tenant),
SecretOwner::project(tenant, project)
);
assert_eq!(
SecretOwner::project(tenant, project).to_string(),
format!("{tenant}/{project}")
);
}
#[test]
fn the_lifecycle_matrix_is_total_and_deterministic() {
use SecretLifecycle::{Active, Disabled, Revoked, Staged, Tombstoned};
let permitted = [
(Staged, Active),
(Staged, Disabled),
(Staged, Revoked),
(Active, Disabled),
(Active, Revoked),
(Disabled, Active),
(Disabled, Revoked),
(Revoked, Tombstoned),
];
for (from, to) in permitted {
assert_eq!(
from.transition_to(to),
Ok(LifecycleTransition::Moved { from, to }),
"{from} -> {to} is permitted"
);
}
for from in SecretLifecycle::ALL.iter().copied() {
for to in SecretLifecycle::ALL.iter().copied() {
let outcome = from.transition_to(to);
if from == to {
assert_eq!(outcome, Ok(LifecycleTransition::Unchanged(from)));
} else if permitted.contains(&(from, to)) {
assert!(outcome.expect("permitted").changed());
} else {
assert_eq!(outcome, Err(ForbiddenTransition { from, to }));
}
}
}
}
#[test]
fn withdrawn_material_is_never_put_back_in_service() {
use SecretLifecycle::{Active, Disabled, Revoked, Staged, Tombstoned};
for to in [Staged, Active, Disabled] {
assert_eq!(
Revoked.transition_to(to),
Err(ForbiddenTransition { from: Revoked, to })
);
}
for to in [Staged, Active, Disabled, Revoked] {
assert_eq!(
Tombstoned.transition_to(to),
Err(ForbiddenTransition {
from: Tombstoned,
to
})
);
}
assert!(Tombstoned.is_terminal());
assert!(
!Revoked.is_terminal(),
"revoked material is still tombstonable"
);
assert!(Revoked.is_withdrawn() && Tombstoned.is_withdrawn());
assert!(!Disabled.is_withdrawn(), "disabling is reversible");
}
#[test]
fn only_staged_and_active_material_resolves() {
use SecretLifecycle::{Active, Disabled, Revoked, Staged, Tombstoned};
assert!(Staged.permits_resolution());
assert!(Active.permits_resolution());
for state in [Disabled, Revoked, Tombstoned] {
assert!(!state.permits_resolution(), "{state} does not resolve");
}
assert_eq!(SecretLifecycle::default(), Staged);
}
#[test]
fn lifecycle_identifiers_round_trip_and_reject_unknown_text() {
for state in SecretLifecycle::ALL.iter().copied() {
assert_eq!(SecretLifecycle::parse(state.as_str()), Some(state));
assert_eq!(state.to_string(), state.as_str());
}
for unknown in ["", "ACTIVE", "deleted", "staged "] {
assert_eq!(SecretLifecycle::parse(unknown), None, "`{unknown}`");
}
}
}