Skip to content

Commit ded438b

Browse files
authored
Drop cached S3 clients on logout so credentials stop working immediately (#766)
1 parent 536d564 commit ded438b

9 files changed

Lines changed: 151 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

quilt-rs/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
<!-- markdownlint-disable MD013 -->
1010
# Changelog
1111

12+
## [v0.33.0-alpha9] - 2026-07-13
13+
14+
### Added
15+
16+
- `RemoteS3` now exposes clearing its cached S3 clients (per host or all) so callers can invalidate credentials on logout (<https://github.com/quiltdata/quilt-rs/pull/766>)
17+
1218
## [v0.33.0-alpha8] - 2026-07-10
1319

1420
### Added

quilt-rs/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "quilt-rs"
33
description = "Rust library for accessing Quilt data packages."
44

55
# Inherit from workspace
6-
version = "0.33.0-alpha8"
6+
version = "0.33.0-alpha9"
77
edition.workspace = true
88
rust-version.workspace = true
99
license.workspace = true

quilt-rs/src/io/remote.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,13 @@ pub trait Remote {
155155
/// HEAD-bucket endpoint returns the region for any existing bucket
156156
/// regardless of permissions.
157157
fn verify_bucket(&self, bucket: &str) -> impl Future<Output = Res> + Send;
158+
159+
/// Drop any cached clients (and their in-memory credentials) for the
160+
/// given `host`, or for all hosts when `None`. Lets callers invalidate
161+
/// credentials immediately on logout instead of waiting for them to
162+
/// expire. Default no-op: only stateful remotes such as [`RemoteS3`]
163+
/// cache clients, so mocks and other impls need no change.
164+
fn clear_client_cache(&self, host: Option<&Host>) {
165+
let _ = host;
166+
}
158167
}

quilt-rs/src/io/remote/s3.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,32 @@ impl RemoteS3 {
290290
}
291291
}
292292

293+
/// Drop cached S3 clients so the next request rebuilds them with a
294+
/// fresh credential provider. Called on logout: a cached client still
295+
/// holds the STS credentials minted before logout (valid ~1h), so
296+
/// without this the running app keeps serving reads/writes after the
297+
/// on-disk auth token is erased.
298+
///
299+
/// `Some(host)` clears only the clients for that catalog host;
300+
/// `None` (global logout) clears every cached client.
301+
///
302+
/// The `regions` cache is left intact: it is keyed by bucket name and
303+
/// holds only public HEAD-bucket region lookups — no credentials — so
304+
/// it never goes stale on logout.
305+
pub fn clear_client_cache(&self, host: Option<&Host>) {
306+
// On a poisoned lock, recover the guard and clear anyway: dropping
307+
// stale clients on logout is the safe outcome, never leaving
308+
// credentials cached.
309+
let mut map = match self.s3.write() {
310+
Ok(map) => map,
311+
Err(poisoned) => poisoned.into_inner(),
312+
};
313+
match host {
314+
Some(host) => map.retain(|creds_ref, _| creds_ref.host.as_ref() != Some(host)),
315+
None => map.clear(),
316+
}
317+
}
318+
293319
async fn get_client_for_bucket(
294320
&self,
295321
host: &Option<Host>,
@@ -439,6 +465,10 @@ impl Remote for RemoteS3 {
439465
self.get_region_for_bucket(bucket).await?;
440466
Ok(())
441467
}
468+
469+
fn clear_client_cache(&self, host: Option<&Host>) {
470+
RemoteS3::clear_client_cache(self, host);
471+
}
442472
}
443473

444474
#[cfg(test)]
@@ -589,6 +619,66 @@ mod tests {
589619
Ok(())
590620
}
591621

622+
/// Building a real, offline `aws_sdk_s3::Client` for a region so the
623+
/// cache-clearing test exercises the actual `s3` map, not a stand-in.
624+
fn dummy_client(region: &str) -> aws_sdk_s3::Client {
625+
let conf = aws_sdk_s3::Config::builder()
626+
.behavior_version(BehaviorVersion::latest())
627+
.region(Region::new(region.to_string()))
628+
.build();
629+
aws_sdk_s3::Client::from_conf(conf)
630+
}
631+
632+
/// `clear_client_cache(Some(host))` drops only that host's clients and
633+
/// keeps the rest; `clear_client_cache(None)` empties the whole cache.
634+
#[test]
635+
fn test_clear_client_cache_filters_by_host() {
636+
use std::str::FromStr;
637+
638+
let host_a = Host::from_str("a.example.com").unwrap();
639+
let host_b = Host::from_str("b.example.com").unwrap();
640+
641+
let remote = RemoteS3::new(DomainPaths::default(), LocalStorage::new());
642+
643+
{
644+
let mut map = remote.s3.write().unwrap();
645+
map.insert(
646+
CredsRef {
647+
region: Region::new("us-east-1"),
648+
host: Some(host_a.clone()),
649+
},
650+
dummy_client("us-east-1"),
651+
);
652+
map.insert(
653+
CredsRef {
654+
region: Region::new("us-west-2"),
655+
host: Some(host_b.clone()),
656+
},
657+
dummy_client("us-west-2"),
658+
);
659+
map.insert(
660+
CredsRef {
661+
region: Region::new("eu-west-1"),
662+
host: None,
663+
},
664+
dummy_client("eu-west-1"),
665+
);
666+
}
667+
668+
// Clearing host_a leaves host_b and the host-less entry.
669+
remote.clear_client_cache(Some(&host_a));
670+
{
671+
let map = remote.s3.read().unwrap();
672+
assert_eq!(map.len(), 2);
673+
assert!(!map.keys().any(|k| k.host.as_ref() == Some(&host_a)));
674+
assert!(map.keys().any(|k| k.host.as_ref() == Some(&host_b)));
675+
}
676+
677+
// Clearing None empties everything.
678+
remote.clear_client_cache(None);
679+
assert!(remote.s3.read().unwrap().is_empty());
680+
}
681+
592682
/// When storage holds valid credentials, the provider must surface them
593683
/// as `aws_credential_types::Credentials` on every call. This proves
594684
/// the async plumbing compiles and runs, and that the quilt-side

quilt-sync/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
<!-- markdownlint-disable MD013 -->
1010
# Changelog
1111

12+
## [v0.18.3-alpha15] - 2026-07-13
13+
14+
### Fixed
15+
16+
- Logging out now immediately drops the in-memory S3 credential cache, so reads and writes stop working right away instead of lingering until the cached credentials expire (<https://github.com/quiltdata/quilt-rs/pull/766>)
17+
1218
## [v0.18.3-alpha14] - 2026-07-13
1319

1420
### Changed

quilt-sync/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "quilt-sync"
3-
version = "0.18.3-alpha14"
3+
version = "0.18.3-alpha15"
44
authors = ["Quilt Data, Inc."]
55
description = "Cross-platform desktop application for editing Quilt data packages"
66
documentation = "https://docs.quiltdata.com"

quilt-sync/src-tauri/src/commands/auth.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use serde::Serialize;
66
use tauri::Manager;
77
use tokio::sync;
88

9+
use quilt_uri::Host;
10+
911
use crate::Error;
1012
use crate::model;
1113
use crate::notify::Notify;
@@ -84,6 +86,7 @@ async fn erase_auth_command(app_handle: &tauri::AppHandle, host: &str) -> Result
8486
#[tauri::command]
8587
pub async fn erase_auth(
8688
app_handle: tauri::State<'_, sync::Mutex<tauri::AppHandle>>,
89+
m: tauri::State<'_, model::Model>,
8790
tracing: tauri::State<'_, crate::telemetry::Telemetry>,
8891
host: String,
8992
) -> Result<String, String> {
@@ -95,11 +98,25 @@ pub async fn erase_auth(
9598
let msg_ok = format!("Successfully erased auth for {host}");
9699
let msg_err = |err: &Error| format!("Failed to erase auth: {err}");
97100

98-
Notify::new(msg_init).map(
99-
erase_auth_command(&app_handle, &host).await,
100-
msg_ok,
101-
msg_err,
102-
)
101+
// Delete the on-disk token first, then invalidate the in-memory S3
102+
// client cache. A cached client holds STS credentials minted before
103+
// logout (valid ~1h), so without this the running app keeps serving
104+
// reads/writes until they expire.
105+
let result = erase_auth_command(&app_handle, &host).await;
106+
if result.is_ok() {
107+
// Global logout (empty host) clears every cached client; a per-host
108+
// logout clears only that host's. An unparseable non-empty host can
109+
// have no client keyed under it (cache keys are valid `Host`s), so
110+
// there is nothing to clear — and we must NOT fall back to clearing
111+
// everything, which would drop unrelated hosts' clients.
112+
if host.is_empty() {
113+
m.clear_remote_client_cache(None).await;
114+
} else if let Ok(host) = Host::from_str(&host) {
115+
m.clear_remote_client_cache(Some(&host)).await;
116+
}
117+
}
118+
119+
Notify::new(msg_init).map(result, msg_ok, msg_err)
103120
}
104121

105122
/// Navigate to a page after successful login.

quilt-sync/src-tauri/src/model.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ use quilt_rs::io::remote::WorkflowsConfig;
1919
use quilt_rs::io::remote::fetch_workflows_config_for_bucket;
2020
use quilt_rs::workflow::WorkflowRules;
2121

22+
use quilt_uri::Host;
23+
2224
/// Result of checking whether a package is already installed.
2325
#[derive(Debug)]
2426
pub enum InstallCheck {
@@ -422,6 +424,18 @@ impl Model {
422424
) -> Result<quilt::lineage::Home, Error> {
423425
Ok(self.get_quilt().lock().await.set_home(directory).await?)
424426
}
427+
428+
/// Drop the remote's cached S3 clients (and their in-memory
429+
/// credentials) for `host`, or for all hosts when `None`. Invoked on
430+
/// logout so credentials stop working immediately rather than
431+
/// lingering in a cached client until STS expiry.
432+
pub async fn clear_remote_client_cache(&self, host: Option<&Host>) {
433+
self.quilt
434+
.lock()
435+
.await
436+
.get_remote()
437+
.clear_client_cache(host);
438+
}
425439
}
426440

427441
mod ops;

0 commit comments

Comments
 (0)