use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::Result;
pub const ACCOUNT_CAPABILITY: &str = "admin.account.info";
pub const ACCOUNT_MFA_CAPABILITY: &str = "admin.account.mfa";
pub const USER_MFA_CAPABILITY: &str = "admin.user.mfa";
pub const MAX_ACCOUNT_RESPONSE_BYTES: usize = 256 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IdentityType {
Root,
Iam,
Sts,
ServiceAccount,
}
impl std::fmt::Display for IdentityType {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Root => "root",
Self::Iam => "iam",
Self::Sts => "sts",
Self::ServiceAccount => "service-account",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CredentialsSource {
Env,
Iam,
}
impl std::fmt::Display for CredentialsSource {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::Env => "env",
Self::Iam => "iam",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AccountMutability {
#[serde(default)]
pub password: bool,
#[serde(default)]
pub username: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AccountMfaSummary {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub pending: bool,
#[serde(default)]
pub activated_at: Option<String>,
#[serde(default)]
pub recovery_codes_remaining: u32,
#[serde(default)]
pub last_verified_at: Option<String>,
#[serde(default)]
pub enrollment_available: bool,
#[serde(default)]
pub enrollment_blocked_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountInfo {
pub access_key: String,
pub identity_type: IdentityType,
#[serde(default)]
pub session_access_key: Option<String>,
pub is_admin: bool,
pub status: String,
#[serde(default)]
pub member_of: Vec<String>,
#[serde(default)]
pub policies: Vec<String>,
pub credentials_source: CredentialsSource,
pub mutable: AccountMutability,
pub mfa: AccountMfaSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MfaStatus {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub pending: bool,
pub algorithm: String,
pub digits: u8,
pub period_seconds: u32,
#[serde(default)]
pub activated_at: Option<String>,
#[serde(default)]
pub pending_expires_at: Option<String>,
#[serde(default)]
pub recovery_codes_remaining: u32,
#[serde(default)]
pub last_verified_at: Option<String>,
#[serde(default)]
pub enrollment_available: bool,
#[serde(default)]
pub enrollment_blocked_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MfaEnrollment {
pub secret_base32: String,
pub otpauth_uri: String,
#[serde(default)]
pub qr_svg: String,
pub qr_utf8: String,
pub algorithm: String,
pub digits: u8,
pub period_seconds: u32,
pub expires_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryCodes {
pub recovery_codes: Vec<String>,
pub generated_at: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct PasswordChangeResult {
#[serde(default)]
pub sessions_revoked: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserMfaStatus {
pub access_key: String,
pub enabled: bool,
#[serde(default)]
pub activated_at: Option<String>,
#[serde(default)]
pub recovery_codes_remaining: u32,
}
#[derive(Clone)]
pub struct SecretValue(Zeroizing<String>);
impl std::fmt::Debug for SecretValue {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("SecretValue([REDACTED])")
}
}
impl SecretValue {
pub fn new(value: String) -> Self {
Self(Zeroizing::new(value))
}
pub fn expose(&self) -> &str {
self.0.as_str()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
#[async_trait]
pub trait AccountApi: Send + Sync {
async fn account_info(&self) -> Result<AccountInfo>;
async fn account_change_password(
&self,
current_secret_key: &SecretValue,
new_secret_key: &SecretValue,
) -> Result<PasswordChangeResult>;
}
#[async_trait]
pub trait AccountMfaApi: Send + Sync {
async fn account_mfa_status(&self) -> Result<MfaStatus>;
async fn account_mfa_enroll(&self) -> Result<MfaEnrollment>;
async fn account_mfa_activate(&self, code: &SecretValue) -> Result<RecoveryCodes>;
async fn account_mfa_disable(
&self,
code: &SecretValue,
current_secret_key: &SecretValue,
) -> Result<()>;
async fn account_mfa_recovery_codes(&self, code: &SecretValue) -> Result<RecoveryCodes>;
}
#[async_trait]
pub trait UserCredentialApi: Send + Sync {
async fn set_user_secret_key(
&self,
access_key: &str,
secret_key: &SecretValue,
) -> Result<PasswordChangeResult>;
async fn user_mfa_status(&self, access_key: &str) -> Result<UserMfaStatus>;
async fn user_mfa_reset(&self, access_key: &str) -> Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_type_renders_the_wire_value() {
assert_eq!(IdentityType::ServiceAccount.to_string(), "service-account");
assert_eq!(IdentityType::Root.to_string(), "root");
assert_eq!(
serde_json::to_string(&IdentityType::ServiceAccount).expect("serialize"),
"\"service-account\""
);
}
#[test]
fn credentials_source_renders_the_wire_value() {
assert_eq!(CredentialsSource::Env.to_string(), "env");
assert_eq!(
serde_json::to_string(&CredentialsSource::Iam).expect("serialize"),
"\"iam\""
);
}
#[test]
fn secret_values_never_print_their_contents() {
let secret = SecretValue::new("super-secret".to_string());
assert_eq!(format!("{secret:?}"), "SecretValue([REDACTED])");
assert!(!format!("{secret:?}").contains("super-secret"));
assert_eq!(secret.expose(), "super-secret");
}
#[test]
fn account_info_decodes_a_minimal_server_response() {
let decoded: AccountInfo = serde_json::from_str(
r#"{
"access_key": "sinan",
"identity_type": "iam",
"is_admin": true,
"status": "enabled",
"credentials_source": "iam",
"mutable": {"password": true, "username": false},
"mfa": {}
}"#,
)
.expect("deserialize");
assert_eq!(decoded.access_key, "sinan");
assert!(decoded.mutable.password);
assert!(!decoded.mfa.enabled);
assert!(decoded.member_of.is_empty());
}
#[test]
fn mfa_status_decodes_without_optional_timestamps() {
let decoded: MfaStatus =
serde_json::from_str(r#"{"algorithm":"SHA1","digits":6,"period_seconds":30}"#)
.expect("deserialize");
assert!(!decoded.enabled);
assert_eq!(decoded.digits, 6);
assert!(decoded.activated_at.is_none());
}
}