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}