Skip to main content

rc_core/admin/
account.rs

1//! Self-service account and two-factor authentication operations.
2//!
3//! These act on whoever the alias authenticates as: none of them take a target
4//! identity, so `rc` cannot use them to touch another account. Managing someone
5//! else's credentials goes through the user-management API instead.
6//!
7//! # Why the CLI never enforces the second factor
8//!
9//! `rc` signs every request with the alias's long-term access key. That path is
10//! not gated by 2FA and must not be: gating it would break every script the
11//! moment a human turned 2FA on for their own account, and it would add no
12//! protection, because whoever holds the secret key already has full access. The
13//! second factor guards session minting — the interactive console login — which
14//! `rc` does not use. This is the same division AWS draws.
15
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18use zeroize::Zeroizing;
19
20use crate::Result;
21
22/// Runtime capability required by the self-service account commands.
23pub const ACCOUNT_CAPABILITY: &str = "admin.account.info";
24
25/// Runtime capability required by the two-factor commands.
26pub const ACCOUNT_MFA_CAPABILITY: &str = "admin.account.mfa";
27
28/// Runtime capability required by the administrative MFA inspection/reset.
29pub const USER_MFA_CAPABILITY: &str = "admin.user.mfa";
30
31/// Response bound for account and MFA payloads.
32///
33/// Generous enough for a QR SVG (a few kilobytes) and a full recovery-code set,
34/// tight enough that a misbehaving endpoint cannot stream unbounded data into
35/// the CLI.
36pub const MAX_ACCOUNT_RESPONSE_BYTES: usize = 256 * 1024;
37
38/// How the calling credential was established.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum IdentityType {
42    Root,
43    Iam,
44    Sts,
45    ServiceAccount,
46}
47
48impl std::fmt::Display for IdentityType {
49    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        formatter.write_str(match self {
51            Self::Root => "root",
52            Self::Iam => "iam",
53            Self::Sts => "sts",
54            Self::ServiceAccount => "service-account",
55        })
56    }
57}
58
59/// Where the identity's long-term secret lives.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "kebab-case")]
62pub enum CredentialsSource {
63    /// Provisioned from the server process environment; immutable at runtime.
64    Env,
65    /// Stored in the IAM object store; mutable through the admin API.
66    Iam,
67}
68
69impl std::fmt::Display for CredentialsSource {
70    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        formatter.write_str(match self {
72            Self::Env => "env",
73            Self::Iam => "iam",
74        })
75    }
76}
77
78/// Which self-service mutations the server will accept for this identity.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
80pub struct AccountMutability {
81    #[serde(default)]
82    pub password: bool,
83    #[serde(default)]
84    pub username: bool,
85}
86
87/// MFA state reported alongside the account summary.
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
89pub struct AccountMfaSummary {
90    #[serde(default)]
91    pub enabled: bool,
92    #[serde(default)]
93    pub pending: bool,
94    #[serde(default)]
95    pub activated_at: Option<String>,
96    #[serde(default)]
97    pub recovery_codes_remaining: u32,
98    #[serde(default)]
99    pub last_verified_at: Option<String>,
100    #[serde(default)]
101    pub enrollment_available: bool,
102    #[serde(default)]
103    pub enrollment_blocked_reason: Option<String>,
104}
105
106/// The identity behind the alias, as the server describes it.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct AccountInfo {
109    pub access_key: String,
110    pub identity_type: IdentityType,
111    #[serde(default)]
112    pub session_access_key: Option<String>,
113    pub is_admin: bool,
114    pub status: String,
115    #[serde(default)]
116    pub member_of: Vec<String>,
117    #[serde(default)]
118    pub policies: Vec<String>,
119    pub credentials_source: CredentialsSource,
120    pub mutable: AccountMutability,
121    pub mfa: AccountMfaSummary,
122}
123
124/// Two-factor state for the calling identity.
125#[derive(Debug, Clone, Serialize, Deserialize, Default)]
126pub struct MfaStatus {
127    #[serde(default)]
128    pub enabled: bool,
129    #[serde(default)]
130    pub pending: bool,
131    pub algorithm: String,
132    pub digits: u8,
133    pub period_seconds: u32,
134    #[serde(default)]
135    pub activated_at: Option<String>,
136    #[serde(default)]
137    pub pending_expires_at: Option<String>,
138    #[serde(default)]
139    pub recovery_codes_remaining: u32,
140    #[serde(default)]
141    pub last_verified_at: Option<String>,
142    #[serde(default)]
143    pub enrollment_available: bool,
144    #[serde(default)]
145    pub enrollment_blocked_reason: Option<String>,
146}
147
148/// A started enrollment.
149///
150/// The shared secret appears here exactly once. `rc` renders it and drops it; it
151/// is never written to the alias config or any other file.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct MfaEnrollment {
154    pub secret_base32: String,
155    pub otpauth_uri: String,
156    /// Server-rendered SVG. Carried for parity with the console; `rc` prints the
157    /// terminal form instead.
158    #[serde(default)]
159    pub qr_svg: String,
160    /// Server-rendered Unicode block art, ready to print.
161    pub qr_utf8: String,
162    pub algorithm: String,
163    pub digits: u8,
164    pub period_seconds: u32,
165    pub expires_at: String,
166}
167
168/// A freshly generated recovery-code set.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct RecoveryCodes {
171    pub recovery_codes: Vec<String>,
172    pub generated_at: String,
173}
174
175/// Sessions invalidated by a credential rotation.
176#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
177pub struct PasswordChangeResult {
178    #[serde(default)]
179    pub sessions_revoked: u32,
180}
181
182/// Another identity's two-factor state, for an administrator.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct UserMfaStatus {
185    pub access_key: String,
186    pub enabled: bool,
187    #[serde(default)]
188    pub activated_at: Option<String>,
189    #[serde(default)]
190    pub recovery_codes_remaining: u32,
191}
192
193/// A secret held only long enough to send, then zeroed.
194///
195/// Wrapped so a password or code cannot survive in a freed allocation, and so
196/// `Debug` cannot print it into a log or a panic message.
197#[derive(Clone)]
198pub struct SecretValue(Zeroizing<String>);
199
200impl std::fmt::Debug for SecretValue {
201    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        formatter.write_str("SecretValue([REDACTED])")
203    }
204}
205
206impl SecretValue {
207    pub fn new(value: String) -> Self {
208        Self(Zeroizing::new(value))
209    }
210
211    pub fn expose(&self) -> &str {
212        self.0.as_str()
213    }
214
215    pub fn is_empty(&self) -> bool {
216        self.0.is_empty()
217    }
218}
219
220#[async_trait]
221pub trait AccountApi: Send + Sync {
222    /// Describe the identity the alias authenticates as.
223    async fn account_info(&self) -> Result<AccountInfo>;
224
225    /// Rotate the alias identity's own secret key.
226    ///
227    /// The current secret is required as proof of knowledge; the server rejects
228    /// the call without it even though the request is signed.
229    async fn account_change_password(
230        &self,
231        current_secret_key: &SecretValue,
232        new_secret_key: &SecretValue,
233    ) -> Result<PasswordChangeResult>;
234}
235
236#[async_trait]
237pub trait AccountMfaApi: Send + Sync {
238    async fn account_mfa_status(&self) -> Result<MfaStatus>;
239
240    /// Start (or restart) an enrollment. Does not change the active factor.
241    async fn account_mfa_enroll(&self) -> Result<MfaEnrollment>;
242
243    /// Confirm a pending enrollment and receive the first recovery-code set.
244    async fn account_mfa_activate(&self, code: &SecretValue) -> Result<RecoveryCodes>;
245
246    /// Turn the factor off. Requires the code and the account password.
247    async fn account_mfa_disable(
248        &self,
249        code: &SecretValue,
250        current_secret_key: &SecretValue,
251    ) -> Result<()>;
252
253    /// Replace the recovery-code set.
254    async fn account_mfa_recovery_codes(&self, code: &SecretValue) -> Result<RecoveryCodes>;
255}
256
257#[async_trait]
258pub trait UserCredentialApi: Send + Sync {
259    /// Reset another identity's secret key, preserving its status and policies.
260    async fn set_user_secret_key(
261        &self,
262        access_key: &str,
263        secret_key: &SecretValue,
264    ) -> Result<PasswordChangeResult>;
265
266    async fn user_mfa_status(&self, access_key: &str) -> Result<UserMfaStatus>;
267
268    /// Clear another identity's second factor: the break-glass path for a user
269    /// who lost both their authenticator and their recovery codes.
270    async fn user_mfa_reset(&self, access_key: &str) -> Result<()>;
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn identity_type_renders_the_wire_value() {
279        assert_eq!(IdentityType::ServiceAccount.to_string(), "service-account");
280        assert_eq!(IdentityType::Root.to_string(), "root");
281        assert_eq!(
282            serde_json::to_string(&IdentityType::ServiceAccount).expect("serialize"),
283            "\"service-account\""
284        );
285    }
286
287    #[test]
288    fn credentials_source_renders_the_wire_value() {
289        assert_eq!(CredentialsSource::Env.to_string(), "env");
290        assert_eq!(
291            serde_json::to_string(&CredentialsSource::Iam).expect("serialize"),
292            "\"iam\""
293        );
294    }
295
296    #[test]
297    fn secret_values_never_print_their_contents() {
298        let secret = SecretValue::new("super-secret".to_string());
299
300        assert_eq!(format!("{secret:?}"), "SecretValue([REDACTED])");
301        assert!(!format!("{secret:?}").contains("super-secret"));
302        assert_eq!(secret.expose(), "super-secret");
303    }
304
305    #[test]
306    fn account_info_decodes_a_minimal_server_response() {
307        // Optional fields are absent on a server that has nothing to report;
308        // decoding must not require them.
309        let decoded: AccountInfo = serde_json::from_str(
310            r#"{
311                "access_key": "sinan",
312                "identity_type": "iam",
313                "is_admin": true,
314                "status": "enabled",
315                "credentials_source": "iam",
316                "mutable": {"password": true, "username": false},
317                "mfa": {}
318            }"#,
319        )
320        .expect("deserialize");
321
322        assert_eq!(decoded.access_key, "sinan");
323        assert!(decoded.mutable.password);
324        assert!(!decoded.mfa.enabled);
325        assert!(decoded.member_of.is_empty());
326    }
327
328    #[test]
329    fn mfa_status_decodes_without_optional_timestamps() {
330        let decoded: MfaStatus =
331            serde_json::from_str(r#"{"algorithm":"SHA1","digits":6,"period_seconds":30}"#)
332                .expect("deserialize");
333
334        assert!(!decoded.enabled);
335        assert_eq!(decoded.digits, 6);
336        assert!(decoded.activated_at.is_none());
337    }
338}