Skip to main content

brows3r_lib/commands/
profiles_cmd.rs

1//! Tauri commands for the profile CRUD surface.
2//!
3//! # Commands
4//!
5//! - `profiles_list`          — union of AWS-discovered + manual + env profiles.
6//! - `profile_get`            — full detail for one profile (non-secret fields only).
7//! - `profile_create_manual`  — create a manual profile; secret persisted via keychain.
8//! - `profile_update`         — patch name / compat flags / region.
9//! - `profile_delete`         — remove profile + keychain entry.
10//! - `profile_validate`       — validate via `sts:GetCallerIdentity` or list-bucket probe.
11//!
12//! # Security contract
13//!
14//! Secret material (`access_key_id`, `secret_access_key`, `session_token`)
15//! crosses the IPC boundary ONCE: inbound on `profile_create_manual`. The
16//! keychain absorbs the secret immediately; the returned [`ProfileSummary`]
17//! never carries secret fields.
18
19use tauri::State;
20
21use crate::profiles::keychain::Secret;
22use crate::{
23    error::AppError,
24    ids::ProfileId,
25    profiles::{
26        validate_profile, CompatFlags, KeychainHandle, ProfileDetail, ProfileStoreHandle,
27        ProfileSummary, ProfileUpdatePatch, ValidationReport,
28    },
29    s3::S3ClientPoolHandle,
30};
31
32/// Return the aggregated list of all profiles.
33///
34/// Union of `~/.aws/credentials`, `~/.aws/config`, env-derived, and manual
35/// profiles.  See [`crate::profiles::ProfileStore::list`] for aggregation rules.
36#[tauri::command]
37pub async fn profiles_list(
38    store: State<'_, ProfileStoreHandle>,
39) -> Result<Vec<ProfileSummary>, AppError> {
40    let store = store.inner.lock().await;
41    let profiles = store.list();
42    Ok(profiles.iter().map(ProfileSummary::from).collect())
43}
44
45/// Return the full detail for a single profile.
46///
47/// Returns `AppError::NotFound` when no profile with `profile_id` exists.
48#[tauri::command]
49pub async fn profile_get(
50    profile_id: ProfileId,
51    store: State<'_, ProfileStoreHandle>,
52) -> Result<ProfileDetail, AppError> {
53    let store = store.inner.lock().await;
54    store
55        .get(&profile_id)
56        .map(|p| ProfileDetail::from(&p))
57        .ok_or_else(|| AppError::NotFound {
58            resource: format!("profile:{}", profile_id.as_str()),
59        })
60}
61
62/// Create a new manual profile.
63///
64/// The secret (`access_key_id`, `secret_access_key`, `session_token`) crosses
65/// the IPC boundary here and is immediately handed off to the keychain.
66/// The returned [`ProfileSummary`] contains no secret fields.
67///
68/// # Validation
69///
70/// Returns `AppError::Validation` when `name` or `access_key_id` is empty.
71#[tauri::command]
72pub async fn profile_create_manual(
73    name: String,
74    access_key_id: String,
75    secret_access_key: String,
76    session_token: Option<String>,
77    default_region: Option<String>,
78    compat_flags: Option<CompatFlags>,
79    store: State<'_, ProfileStoreHandle>,
80    keychain: State<'_, KeychainHandle>,
81) -> Result<ProfileSummary, AppError> {
82    if name.trim().is_empty() {
83        return Err(AppError::Validation {
84            field: "name".to_string(),
85            hint: "must not be empty".to_string(),
86        });
87    }
88    if access_key_id.trim().is_empty() {
89        return Err(AppError::Validation {
90            field: "accessKeyId".to_string(),
91            hint: "must not be empty".to_string(),
92        });
93    }
94
95    let secret = Secret {
96        access_key_id,
97        secret_access_key,
98        session_token,
99    };
100
101    let mut store = store.inner.lock().await;
102    let mut keychain = keychain.inner.lock().await;
103
104    let profile =
105        store.create_manual(name, secret, default_region, compat_flags, &mut *keychain)?;
106    Ok(ProfileSummary::from(&profile))
107}
108
109/// Update a manual profile's display name, compat flags, and/or default region.
110///
111/// Returns `AppError::NotFound` when the profile does not exist or is not a
112/// manual profile (discovered profiles are read-only).
113#[tauri::command]
114pub async fn profile_update(
115    profile_id: ProfileId,
116    patch: ProfileUpdatePatch,
117    store: State<'_, ProfileStoreHandle>,
118) -> Result<ProfileSummary, AppError> {
119    let mut store = store.inner.lock().await;
120    let updated = store.update(&profile_id, patch)?;
121    Ok(ProfileSummary::from(&updated))
122}
123
124/// Delete a manual profile and its associated keychain entry.
125///
126/// Returns `AppError::NotFound` when the profile does not exist in the manual
127/// set (discovered profiles cannot be deleted).
128#[tauri::command]
129pub async fn profile_delete(
130    profile_id: ProfileId,
131    store: State<'_, ProfileStoreHandle>,
132    keychain: State<'_, KeychainHandle>,
133) -> Result<(), AppError> {
134    let mut store = store.inner.lock().await;
135    let mut keychain = keychain.inner.lock().await;
136    store.delete(&profile_id, &mut *keychain)
137}
138
139/// Unlock the keychain fallback (FileBackend) with a user-supplied passphrase.
140///
141/// Called from the `KeychainFallbackPrompt` UI component when the OS keychain
142/// is unavailable and the app has fallen back to the encrypted-file backend.
143///
144/// The passphrase is forwarded to `KeychainBackend::unlock`. For backends
145/// that do not need a passphrase (e.g. `KeyringBackend`) this is a no-op that
146/// returns `Ok(())`. Full FileBackend re-key logic is wired here; the trait
147/// method default handles all other backends transparently.
148///
149/// # TODO
150///
151/// Wire `keychain_dir` + passphrase back into a live `FileBackend` once the
152/// app emits `KeychainFallbackRequired` at startup (task 19+). For now this
153/// command delegates to the generic `unlock` trait method so the frontend
154/// pathway is exercised end-to-end without requiring a complete startup rework.
155#[tauri::command]
156pub async fn keychain_fallback_unlock(
157    passphrase: String,
158    keychain: State<'_, KeychainHandle>,
159) -> Result<(), AppError> {
160    let mut keychain = keychain.inner.lock().await;
161    keychain.unlock(&passphrase)
162}
163
164/// Validate a profile by running the appropriate probe.
165///
166/// - AWS profiles (no `endpoint_url`): `sts:GetCallerIdentity`.
167/// - Compat providers (has `endpoint_url`): `s3:ListBuckets`.
168///
169/// On success, persists `validated_at` via `ProfileStore::mark_validated`.
170/// Returns the full [`ValidationReport`] regardless of success/failure.
171#[tauri::command]
172pub async fn profile_validate(
173    profile_id: ProfileId,
174    store: State<'_, ProfileStoreHandle>,
175    keychain: State<'_, KeychainHandle>,
176    pool: State<'_, S3ClientPoolHandle>,
177) -> Result<ValidationReport, AppError> {
178    // 1. Look up the profile.
179    let profile = {
180        let store = store.inner.lock().await;
181        store.get(&profile_id).ok_or_else(|| AppError::NotFound {
182            resource: format!("profile:{}", profile_id.as_str()),
183        })?
184    };
185
186    // 2. Fetch the secret from keychain for manual profiles.
187    //    AWS-discovered / env profiles rely on the SDK's credential provider chain.
188    let secret = {
189        use crate::profiles::ProfileSource;
190        if profile.source == ProfileSource::Manual {
191            let keychain = keychain.inner.lock().await;
192            keychain.get(profile_id.as_str())?
193        } else {
194            None
195        }
196    };
197
198    // 3. Run the validation probe.
199    let report = validate_profile(&profile, secret.as_ref(), &pool.inner).await?;
200
201    // 4. Persist validated_at on success and register the profile with the
202    //    shared S3 client pool so subsequent buckets_list / objects_list
203    //    calls can build a client. Without this every post-validate command
204    //    would fail with Internal { trace_id: "pool_miss:..." } because the
205    //    pool only learns about a profile via register_profile().
206    if report.ok {
207        {
208            let mut store = store.inner.lock().await;
209            store.mark_validated(&profile_id, report.validated_at);
210        }
211        pool.inner
212            .register_profile(profile_id.clone(), profile.compat_flags.clone())
213            .await;
214
215        // Also register credentials so get_or_build can sign requests. Without
216        // this the pool falls back to the SDK's default chain — which only
217        // loads the user's default ~/.aws/credentials profile — and every
218        // request against a non-default profile fails with "dispatch failure".
219        if let Some(creds) = build_credentials_provider(&profile, secret.as_ref()).await {
220            pool.inner
221                .register_credentials(profile_id.clone(), creds)
222                .await;
223        }
224    }
225
226    Ok(report)
227}
228
229/// Build a `SharedCredentialsProvider` for a profile.
230///
231/// - Manual profiles: use the keychain secret directly.
232/// - AWS-discovered profiles: load the full SDK config for the named profile so
233///   the SSO / assume-role / credential-process providers are wired in. A bare
234///   `ProfileFileCredentialsProvider` only understands static `aws_access_key_id`
235///   entries and silently fails on SSO profiles.
236/// - Env profiles: fall through to None — the SDK's default chain will pick up
237///   the env vars on its own.
238///
239/// Returns `None` when no provider can be constructed.
240async fn build_credentials_provider(
241    profile: &crate::profiles::Profile,
242    secret: Option<&crate::profiles::keychain::Secret>,
243) -> Option<aws_credential_types::provider::SharedCredentialsProvider> {
244    use crate::profiles::ProfileSource;
245    use aws_config::BehaviorVersion;
246    use aws_credential_types::provider::SharedCredentialsProvider;
247    use aws_credential_types::Credentials;
248
249    if let Some(secret) = secret {
250        let creds = Credentials::new(
251            &secret.access_key_id,
252            &secret.secret_access_key,
253            secret.session_token.clone(),
254            None,
255            "brows3r-manual",
256        );
257        return Some(SharedCredentialsProvider::new(creds));
258    }
259
260    match profile.source {
261        ProfileSource::AwsCredentials | ProfileSource::AwsConfig => {
262            let sdk_config = aws_config::defaults(BehaviorVersion::latest())
263                .profile_name(profile.display_name.as_str())
264                .load()
265                .await;
266            sdk_config.credentials_provider()
267        }
268        // Env/Manual paths handled above; nothing to register here.
269        _ => None,
270    }
271}