use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
Aws,
Gitlab,
Cloudflare,
Datadog,
Npm,
DockerHub,
OpenAI,
Anthropic,
Twilio,
SendGrid,
GithubApp,
}
impl ProviderKind {
pub fn display_name(&self) -> &'static str {
match self {
ProviderKind::Aws => "AWS IAM",
ProviderKind::Gitlab => "GitLab",
ProviderKind::Cloudflare => "Cloudflare",
ProviderKind::Datadog => "Datadog",
ProviderKind::Npm => "npm",
ProviderKind::DockerHub => "Docker Hub",
ProviderKind::OpenAI => "OpenAI",
ProviderKind::Anthropic => "Anthropic",
ProviderKind::Twilio => "Twilio",
ProviderKind::SendGrid => "SendGrid",
ProviderKind::GithubApp => "GitHub App",
}
}
}
impl std::str::FromStr for ProviderKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"aws" | "aws-iam" | "iam" => Ok(ProviderKind::Aws),
"gitlab" | "gl" => Ok(ProviderKind::Gitlab),
"cloudflare" | "cf" => Ok(ProviderKind::Cloudflare),
"datadog" | "dd" => Ok(ProviderKind::Datadog),
"npm" => Ok(ProviderKind::Npm),
"dockerhub" | "docker" | "docker-hub" => Ok(ProviderKind::DockerHub),
"openai" => Ok(ProviderKind::OpenAI),
"anthropic" => Ok(ProviderKind::Anthropic),
"twilio" => Ok(ProviderKind::Twilio),
"sendgrid" | "sg" => Ok(ProviderKind::SendGrid),
"github-app" | "githubapp" | "gha" => Ok(ProviderKind::GithubApp),
other => Err(format!("unknown provider '{other}'")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationStyle {
Overlap,
AtomicRotate,
Manual,
Ephemeral,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Consumer {
DotEnvFile {
path: String,
variable: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
id_variable: Option<String>,
},
GithubActionsSecret { repo: String, secret_name: String },
AwsSsmParameter { name: String, region: String },
File { path: String },
Keychain { service: String, account: String },
#[serde(skip)]
Reveal,
}
impl Consumer {
pub fn describe(&self) -> String {
match self {
Consumer::DotEnvFile {
path,
variable,
id_variable,
} => match id_variable {
Some(idv) => format!("{variable} + {idv} in {path}"),
None => format!("{variable} in {path}"),
},
Consumer::GithubActionsSecret { repo, secret_name } => {
format!("GitHub secret {secret_name} in {repo}")
}
Consumer::AwsSsmParameter { name, region } => format!("SSM {name} ({region})"),
Consumer::File { path } => format!("file {path}"),
Consumer::Keychain { service, account } => {
format!("keychain {service}/{account}")
}
Consumer::Reveal => "printed output".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Secret {
pub id: Uuid,
pub provider: ProviderKind,
pub external_id: String,
pub label: String,
pub principal: String,
#[serde(default)]
pub consumers: Vec<Consumer>,
}
impl Secret {
pub fn new(
provider: ProviderKind,
external_id: impl Into<String>,
label: impl Into<String>,
principal: impl Into<String>,
) -> Self {
Self {
id: Uuid::new_v4(),
provider,
external_id: external_id.into(),
label: label.into(),
principal: principal.into(),
consumers: Vec::new(),
}
}
}
#[derive(Clone)]
pub struct NewCredential {
pub external_id: String,
pub secret_value: String,
}
impl NewCredential {
pub fn new(external_id: impl Into<String>, secret_value: impl Into<String>) -> Self {
Self {
external_id: external_id.into(),
secret_value: secret_value.into(),
}
}
}
impl std::fmt::Debug for NewCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NewCredential")
.field("external_id", &self.external_id)
.field("secret_value", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationStage {
Creating,
UpdatingConsumers,
Verifying,
DeactivatingOld,
DeletingOld,
RolledBack,
Done,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OldKeyState {
Revoked,
Deactivated,
Deleted,
StillActive,
NotApplicable,
}
#[derive(Debug, Clone)]
pub struct RotationOutcome {
pub new_external_id: String,
pub old_key: OldKeyState,
pub updated_consumers: Vec<Consumer>,
pub failed_consumers: Vec<(Consumer, String)>,
pub revealed_secret: Option<String>,
pub rescue_file: Option<std::path::PathBuf>,
pub problems: Vec<String>,
}
impl RotationOutcome {
pub fn is_clean(&self) -> bool {
self.problems.is_empty() && self.failed_consumers.is_empty()
}
}
#[derive(Debug, thiserror::Error)]
pub enum RotationError {
#[error("not authorized: {0}")]
NotAuthorized(String),
#[error("provider limit reached: {0}")]
ProviderLimit(String),
#[error("verification failed: {0}")]
VerificationFailed(String),
#[error("consumer update failed: {0}")]
ConsumerUpdate(String),
#[error("command `{command}` failed: {stderr}")]
CliFailure { command: String, stderr: String },
#[error("http error: {0}")]
Http(String),
#[error("not supported: {0}")]
NotImplemented(String),
#[error("manual action required: {0}")]
ManualActionRequired(String),
#[error(
"nowhere to put the new secret for {0} — refusing to rotate. \
Add a consumer (e.g. --dotenv, --secret-file, --print-secret, or a rekey.toml entry)."
)]
NoDestination(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}