Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions awsenc-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zeroize = { version = "1", features = ["derive"] }
rpassword = "7"
regex = "1"
fs4 = "0.9"

[build-dependencies]
enclaveapp-build-support = { workspace = true }
Expand Down
61 changes: 61 additions & 0 deletions awsenc-cli/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ use awsenc_core::credential::{AwsCredentials, CredentialProcessOutput, Credentia
use awsenc_core::okta::{OktaClient, OktaSession};
use awsenc_core::sts::{self, StsClient};
use enclaveapp_app_storage::EncryptionStorage;
use fs4::fs_std::FileExt;
use std::fs::{self, OpenOptions};
use std::path::{Path, PathBuf};

use crate::cli::ServeArgs;
use crate::usage;
Expand All @@ -18,10 +21,25 @@ type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
///
/// Outputs JSON to stdout. Never prompts for input. Never prints anything to
/// stdout except the credential JSON.
///
/// Serialized per-profile with an exclusive advisory file lock
/// (`<cache>.lock`). When two AWS CLI invocations fire
/// `credential_process = awsenc serve` concurrently on the same profile
/// and the cache is in Refresh or Expired state, without the lock both
/// paths hit STS (and possibly the Okta transparent-reauth chain) —
/// wasting STS quota, risking Okta rate limits, and causing one
/// writer's fresh cache to be silently overwritten. With the lock, the
/// second caller blocks until the first finishes, then re-reads the
/// already-refreshed cache and returns.
#[allow(clippy::print_stderr)]
pub async fn run_serve(args: &ServeArgs, storage: &dyn EncryptionStorage) -> Result<()> {
let profile = resolve_serve_profile(args)?;

let lock_path = cache::cache_path(&profile)
.map(serve_lock_path_for_cache)
.unwrap_or_else(|_| PathBuf::from(format!("/tmp/awsenc-{profile}.lock")));
let _guard = ServeLock::acquire(&lock_path)?;

let Some(cache) = cache::read_cache(&profile)? else {
eprintln!("No cached credentials for profile '{profile}'");
eprintln!("Run: awsenc auth --profile {profile}");
Expand Down Expand Up @@ -197,6 +215,49 @@ async fn try_transparent_reauth(
Ok(creds)
}

/// Derive the per-profile serve lock path from its cache path.
fn serve_lock_path_for_cache(cache_path: PathBuf) -> PathBuf {
let mut path = cache_path;
let mut name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(".lock");
path.set_file_name(name);
path
}

/// Exclusive advisory lock held for the duration of a single
/// `awsenc serve` invocation so that concurrent `credential_process`
/// calls from parallel AWS CLI commands don't duplicate STS / Okta
/// traffic. Released on drop.
struct ServeLock {
file: fs::File,
}

impl ServeLock {
fn acquire(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?;
FileExt::lock_exclusive(&file)
.map_err(|e| format!("acquiring serve lock on {}: {e}", path.display()))?;
Ok(Self { file })
}
}

impl Drop for ServeLock {
fn drop(&mut self) {
drop(FileExt::unlock(&self.file));
}
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
Expand Down