Skip to content

Commit d66b8a0

Browse files
alukachclaude
andcommitted
feat: exec, to run a command with credentials and the proxy endpoint
source-coop exec -- aws s3 ls s3://acct/prod/ runs any S3 tool as the signed-in user, as aws-vault exec does: the role's current credentials (cached, or minted as creds would) go into the command's environment, with the proxy as AWS_ENDPOINT_URL(_S3) and S3_ENDPOINT_URL, a region if none is set, and AWS_PROFILE removed. On Unix the CLI becomes the command, so its exit code and signals reach the shell directly. creds and exec share current_creds(), which mint() now takes a role and proxy for rather than CredsArgs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f31ae44 commit d66b8a0

4 files changed

Lines changed: 243 additions & 41 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ eval $(source-coop creds --format env)
132132

133133
This sets `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in your current shell.
134134

135+
### Running a command with your credentials (`exec`)
136+
137+
`source-coop exec` runs any S3 tool with your credentials and the data proxy as its endpoint, without a profile or exported variables, as `aws-vault exec` does for AWS:
138+
139+
```bash
140+
source-coop exec -- aws s3 ls s3://my-org/my-product/
141+
source-coop exec -- aws s3 sync ./data s3://my-org/my-product/data/
142+
source-coop exec -- s5cmd cp 's3://my-org/my-product/*.parquet' ./local/
143+
( source .env.staging && source-coop exec -- aws s3 ls s3://my-org/ ) # staging
144+
```
145+
146+
The command gets `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` and `AWS_CREDENTIAL_EXPIRATION`, and the proxy URL as `AWS_ENDPOINT_URL_S3` (the AWS CLI v2.13+ and SDKs, for S3 only, so calls to other AWS services are unaffected) and `S3_ENDPOINT_URL` (s5cmd). `AWS_REGION` is set to `us-east-1` only if no region is set already (an empty one counts as unset). `AWS_PROFILE` and `AWS_DEFAULT_PROFILE` are removed so a profile's own credentials can't be mistaken for these, and `AWS_IGNORE_CONFIGURED_ENDPOINT_URLS` is removed so the command can't send them to AWS instead of the proxy. `--role-arn` and `--proxy-url` (or `SOURCE_ROLE_ARN` and `SOURCE_PROXY_URL`) pick the role and environment, as for `creds`. Everything after `--` is the command, flags included.
147+
148+
The command's exit code is passed on. Credentials are fetched once, when it starts: cached ones are used only with at least 15 minutes left, otherwise new ones are fetched. A command still running when they expire (an hour after they're fetched, unless you logged in with `--duration`) needs running again. If you're not signed in, `exec` exits with 4 without running the command.
149+
135150
### Multiple roles
136151

137152
> [!WARNING]

‎src/cache.rs‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -366,19 +366,16 @@ pub(crate) fn write_file(path: &Path, json: &str) -> Result<String, String> {
366366
Ok(path.display().to_string())
367367
}
368368

369-
/// Check if credentials are expired or will expire within a 60-second buffer.
370-
pub fn is_expired(creds: &Credentials) -> Result<bool, String> {
369+
/// Whether `creds` run out within `left` from now (or already have).
370+
pub fn expires_within(creds: &Credentials, left: chrono::Duration) -> Result<bool, String> {
371371
let expiration = chrono::DateTime::parse_from_rfc3339(&creds.expiration).map_err(|e| {
372372
format!(
373373
"Failed to parse expiration timestamp '{}': {e}",
374374
creds.expiration
375375
)
376376
})?;
377377

378-
let now = Utc::now();
379-
let buffer = chrono::Duration::seconds(60);
380-
381-
Ok(expiration <= now + buffer)
378+
Ok(expiration <= Utc::now() + left)
382379
}
383380

384381
#[cfg(test)]
@@ -497,28 +494,37 @@ mod tests {
497494
fn expired_future_date() {
498495
let future = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
499496
let creds = sample_creds(&future);
500-
assert!(!is_expired(&creds).unwrap());
497+
assert!(!expires_within(&creds, chrono::Duration::seconds(60)).unwrap());
501498
}
502499

503500
#[test]
504501
fn expired_past_date() {
505502
let past = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
506503
let creds = sample_creds(&past);
507-
assert!(is_expired(&creds).unwrap());
504+
assert!(expires_within(&creds, chrono::Duration::seconds(60)).unwrap());
508505
}
509506

510507
#[test]
511508
fn expired_within_buffer() {
512509
// 30 seconds from now is within the 60s buffer
513510
let near_future = (Utc::now() + chrono::Duration::seconds(30)).to_rfc3339();
514511
let creds = sample_creds(&near_future);
515-
assert!(is_expired(&creds).unwrap());
512+
assert!(expires_within(&creds, chrono::Duration::seconds(60)).unwrap());
516513
}
517514

518515
#[test]
519516
fn expired_invalid_timestamp() {
520517
let creds = sample_creds("not-a-timestamp");
521-
assert!(is_expired(&creds).is_err());
518+
assert!(expires_within(&creds, chrono::Duration::seconds(60)).is_err());
519+
}
520+
521+
#[test]
522+
fn expires_within_a_longer_margin() {
523+
// Fine for `creds`, too short for `exec`'s 15 minutes.
524+
let soon = (Utc::now() + chrono::Duration::minutes(10)).to_rfc3339();
525+
let creds = sample_creds(&soon);
526+
assert!(!expires_within(&creds, chrono::Duration::seconds(60)).unwrap());
527+
assert!(expires_within(&creds, chrono::Duration::minutes(15)).unwrap());
522528
}
523529

524530
#[test]

‎src/exec.rs‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//! `source-coop exec`: run any S3 tool (the AWS CLI, s5cmd, rclone, an SDK
2+
//! script) as the signed-in user, with credentials and the data proxy's
3+
//! endpoint in its environment, as `aws-vault exec` does for AWS.
4+
5+
use crate::sts::Credentials;
6+
use std::process::Command;
7+
8+
/// The environment `command` runs with: the credentials, where they expire,
9+
/// and the proxy as the S3 endpoint under the names the common tools read.
10+
/// A region is only given when none is set, as the proxy ignores it but
11+
/// signing needs one.
12+
pub fn vars(creds: &Credentials, proxy_url: &str, region_set: bool) -> Vec<(&'static str, String)> {
13+
let endpoint = proxy_url.trim_end_matches('/').to_string();
14+
let mut vars = vec![
15+
("AWS_ACCESS_KEY_ID", creds.access_key_id.clone()),
16+
("AWS_SECRET_ACCESS_KEY", creds.secret_access_key.clone()),
17+
("AWS_SESSION_TOKEN", creds.session_token.clone()),
18+
("AWS_CREDENTIAL_EXPIRATION", creds.expiration.clone()),
19+
// The AWS CLI (v2.13+) and SDKs, for S3 alone: other services (STS,
20+
// KMS) a script calls keep their own endpoints.
21+
("AWS_ENDPOINT_URL_S3", endpoint.clone()),
22+
// s5cmd.
23+
("S3_ENDPOINT_URL", endpoint),
24+
];
25+
if !region_set {
26+
vars.push(("AWS_REGION", "us-east-1".into()));
27+
vars.push(("AWS_DEFAULT_REGION", "us-east-1".into()));
28+
}
29+
vars
30+
}
31+
32+
/// Run `command` with `vars` added to the environment, and leave with its
33+
/// exit code. On Unix this process becomes the command, so signals (Ctrl+C)
34+
/// and the exit status reach the shell as if it had been run directly.
35+
pub fn run(command: &[String], vars: Vec<(&'static str, String)>) -> Result<(), String> {
36+
let (program, args) = command.split_first().ok_or("Name a command to run.")?;
37+
let mut cmd = Command::new(program);
38+
cmd.args(args).envs(vars);
39+
// A profile could hold other credentials or an endpoint; the env vars
40+
// already win, but naming one invites confusion over which applied. And
41+
// tools told to ignore configured endpoints would send the credentials
42+
// to AWS itself rather than the proxy.
43+
for name in [
44+
"AWS_PROFILE",
45+
"AWS_DEFAULT_PROFILE",
46+
"AWS_IGNORE_CONFIGURED_ENDPOINT_URLS",
47+
] {
48+
cmd.env_remove(name);
49+
}
50+
let failed = |e: std::io::Error| format!("Couldn't run {program}: {e}");
51+
52+
#[cfg(unix)]
53+
{
54+
use std::os::unix::process::CommandExt;
55+
// Only returns if the command couldn't be started.
56+
Err(failed(cmd.exec()))
57+
}
58+
#[cfg(not(unix))]
59+
{
60+
let status = cmd.status().map_err(failed)?;
61+
std::process::exit(status.code().unwrap_or(1));
62+
}
63+
}
64+
65+
#[cfg(test)]
66+
mod tests {
67+
use super::*;
68+
69+
fn creds() -> Credentials {
70+
Credentials {
71+
access_key_id: "AKID".into(),
72+
secret_access_key: "SECRET".into(),
73+
session_token: "TOKEN".into(),
74+
expiration: "2026-10-14T13:00:00Z".into(),
75+
}
76+
}
77+
78+
#[test]
79+
fn the_command_gets_credentials_and_the_proxy_as_its_endpoint() {
80+
let vars = vars(&creds(), "https://data.staging.source.coop/", false);
81+
let get = |k: &str| {
82+
vars.iter()
83+
.find(|(name, _)| *name == k)
84+
.map(|(_, v)| v.as_str())
85+
};
86+
assert_eq!(get("AWS_ACCESS_KEY_ID"), Some("AKID"));
87+
assert_eq!(get("AWS_SESSION_TOKEN"), Some("TOKEN"));
88+
assert_eq!(
89+
get("AWS_CREDENTIAL_EXPIRATION"),
90+
Some("2026-10-14T13:00:00Z")
91+
);
92+
for name in ["AWS_ENDPOINT_URL_S3", "S3_ENDPOINT_URL"] {
93+
assert_eq!(
94+
get(name),
95+
Some("https://data.staging.source.coop"),
96+
"{name}"
97+
);
98+
}
99+
assert_eq!(get("AWS_ENDPOINT_URL"), None, "only S3 goes to the proxy");
100+
assert_eq!(get("AWS_REGION"), Some("us-east-1"));
101+
}
102+
103+
#[test]
104+
fn a_region_already_set_is_kept() {
105+
let vars = vars(&creds(), "https://data.source.coop", true);
106+
assert!(!vars.iter().any(|(name, _)| name.contains("REGION")));
107+
}
108+
109+
#[test]
110+
fn a_missing_command_is_an_error() {
111+
assert_eq!(run(&[], vec![]), Err("Name a command to run.".to_string()));
112+
}
113+
}

‎src/main.rs‎

Lines changed: 99 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod api_cmd;
22
mod cache;
3+
mod exec;
34
mod failure;
45
mod oidc;
56
mod output;
@@ -88,6 +89,26 @@ enum Commands {
8889
Login(LoginArgs),
8990
/// Make any request to the source.coop API, signed in as you
9091
Api(ApiCommandArgs),
92+
/// Run a command (the AWS CLI, s5cmd, an SDK script) with credentials and
93+
/// the data proxy as its S3 endpoint in its environment
94+
/// (e.g. `source-coop exec -- aws s3 ls s3://my-org/my-product/`)
95+
Exec(ExecArgs),
96+
}
97+
98+
#[derive(Parser)]
99+
struct ExecArgs {
100+
/// Role ARN whose credentials to use
101+
#[arg(long, env = "SOURCE_ROLE_ARN", default_value = defaults::ROLE_ARN)]
102+
role_arn: String,
103+
104+
/// S3 proxy URL: the environment whose credentials to use, and the
105+
/// endpoint the command is pointed at
106+
#[arg(long, env = "SOURCE_PROXY_URL", default_value = defaults::PROXY_URL)]
107+
proxy_url: String,
108+
109+
/// The command to run, with its arguments, after `--`
110+
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
111+
command: Vec<String>,
91112
}
92113

93114
#[derive(Parser)]
@@ -293,6 +314,11 @@ async fn main() {
293314
fail(e, false);
294315
}
295316
}
317+
Commands::Exec(args) => {
318+
if let Err(e) = run_exec(args, verbose).await {
319+
fail(e, false);
320+
}
321+
}
296322
}
297323
}
298324

@@ -779,24 +805,36 @@ async fn run_login(args: LoginArgs, verbose: bool) -> Result<(), String> {
779805
Ok(())
780806
}
781807

782-
async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> {
783-
let env = cache::env(&args.proxy_url)?;
784-
let env = env.as_deref();
785-
let cached = cache::read_credentials(env, &args.role_arn)?;
786-
let entry = match cached {
787-
Some(entry) if !cache::is_expired(&entry.creds)? => entry,
788-
_ => {
789-
// Re-check under the lock: another process may have minted already.
790-
let _lock = cache::lock(env, &args.role_arn)?;
791-
match cache::read_credentials(env, &args.role_arn)? {
792-
Some(entry) if !cache::is_expired(&entry.creds)? => entry,
793-
stale => mint(&args, env, stale, verbose).await.map_err(|e| {
794-
format!("Couldn't get new credentials ({e}). Run 'source-coop auth login'.")
795-
})?,
796-
}
797-
}
798-
};
808+
/// Run the command with the role's credentials. They're fetched once, so
809+
/// cached ones are only reused with `EXEC_MIN_LIFETIME` left; a command
810+
/// running past their expiry (an hour unless `login --duration` said
811+
/// otherwise) needs running again.
812+
async fn run_exec(args: ExecArgs, verbose: bool) -> Result<(), String> {
813+
let entry = current_creds(&args.role_arn, &args.proxy_url, EXEC_MIN_LIFETIME, verbose).await?;
814+
// Set but empty is unset, as signing would fail on an empty region.
815+
let region_set = ["AWS_REGION", "AWS_DEFAULT_REGION"]
816+
.iter()
817+
.any(|v| std::env::var_os(v).is_some_and(|r| !r.is_empty()));
818+
let vars = exec::vars(&entry.creds, &args.proxy_url, region_set);
819+
if verbose {
820+
eprintln!(
821+
"[verbose] Running {:?} against {} with credentials expiring {}",
822+
args.command, args.proxy_url, entry.creds.expiration
823+
);
824+
}
825+
exec::run(&args.command, vars)
826+
}
827+
828+
/// How long cached credentials must still last for `exec` to hand them to a
829+
/// command, which can't ask for new ones as `credential_process` can.
830+
const EXEC_MIN_LIFETIME: chrono::Duration = chrono::Duration::minutes(15);
831+
832+
/// How long cached credentials must still last for `creds`: its callers ask
833+
/// again when they run out.
834+
const CREDS_MIN_LIFETIME: chrono::Duration = chrono::Duration::seconds(60);
799835

836+
async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> {
837+
let entry = current_creds(&args.role_arn, &args.proxy_url, CREDS_MIN_LIFETIME, verbose).await?;
800838
match args.format {
801839
OutputFormat::CredentialProcess => output::print_credential_process(&entry.creds),
802840
OutputFormat::Env => output::print_env(&entry.creds),
@@ -805,36 +843,66 @@ async fn run_creds(args: CredsArgs, verbose: bool) -> Result<(), String> {
805843
Ok(())
806844
}
807845

846+
/// The role's credentials at `proxy_url`'s environment: the cached ones while
847+
/// they have `min_left` to run, otherwise new ones, minted once however many
848+
/// ask at a time.
849+
async fn current_creds(
850+
role_arn: &str,
851+
proxy_url: &str,
852+
min_left: chrono::Duration,
853+
verbose: bool,
854+
) -> Result<cache::CacheEntry, String> {
855+
let env = cache::env(proxy_url)?;
856+
let env = env.as_deref();
857+
let fresh = |entry: &cache::CacheEntry| -> Result<bool, String> {
858+
Ok(!cache::expires_within(&entry.creds, min_left)?)
859+
};
860+
match cache::read_credentials(env, role_arn)? {
861+
Some(entry) if fresh(&entry)? => return Ok(entry),
862+
_ => {}
863+
}
864+
// Re-check under the lock: another process may have minted already.
865+
let _lock = cache::lock(env, role_arn)?;
866+
match cache::read_credentials(env, role_arn)? {
867+
Some(entry) if fresh(&entry)? => Ok(entry),
868+
stale => mint(role_arn, proxy_url, env, stale, verbose)
869+
.await
870+
.map_err(|e| match e.as_str() {
871+
NOT_LOGGED_IN => "Not signed in. Run 'source-coop auth login'.".to_string(),
872+
_ => format!("Couldn't get new credentials ({e}). Run 'source-coop auth login'."),
873+
}),
874+
}
875+
}
876+
877+
/// `mint`'s error when there's no login session to mint with.
878+
const NOT_LOGGED_IN: &str = "not logged in";
879+
808880
/// New credentials for the role, cached before they're returned. An entry
809881
/// from before the session was shared refreshes with its own token; anything
810882
/// else exchanges the session's ID token, refreshing the session if need be.
811883
async fn mint(
812-
args: &CredsArgs,
884+
role_arn: &str,
885+
proxy_url: &str,
813886
env: Option<&str>,
814887
stale: Option<cache::CacheEntry>,
815888
verbose: bool,
816889
) -> Result<cache::CacheEntry, String> {
817-
let save = |e: &cache::CacheEntry| cache::write_credentials(env, &args.role_arn, e).map(drop);
890+
let save = |e: &cache::CacheEntry| cache::write_credentials(env, role_arn, e).map(drop);
818891
match stale {
819892
Some(entry) if entry.sts.is_none() && entry.refresh.is_some() => {
820-
refresh(&args.role_arn, entry, verbose, save).await
893+
refresh(role_arn, entry, verbose, save).await
821894
}
822895
stale => {
823896
let sts = stale.and_then(|e| e.sts).unwrap_or(cache::StsSettings {
824-
proxy_url: args.proxy_url.clone(),
897+
proxy_url: proxy_url.to_string(),
825898
duration: None,
826899
});
827900
let id_token = session::token(env, session::Want::Id, verbose)
828901
.await?
829-
.ok_or("not logged in")?;
830-
let creds = sts::assume_role(
831-
&sts.proxy_url,
832-
&args.role_arn,
833-
&id_token,
834-
sts.duration,
835-
verbose,
836-
)
837-
.await?;
902+
.ok_or(NOT_LOGGED_IN)?;
903+
let creds =
904+
sts::assume_role(&sts.proxy_url, role_arn, &id_token, sts.duration, verbose)
905+
.await?;
838906
let entry = cache::CacheEntry {
839907
creds,
840908
sts: Some(sts),
@@ -1436,7 +1504,7 @@ mod tests {
14361504
let entry = result.unwrap();
14371505

14381506
assert_eq!(entry.creds.access_key_id, "NEWKEY");
1439-
assert!(!cache::is_expired(&entry.creds).unwrap());
1507+
assert!(!cache::expires_within(&entry.creds, chrono::Duration::seconds(60)).unwrap());
14401508
assert_eq!(entry.refresh.unwrap().refresh_token, "new-rt");
14411509
// Rotated token persisted first (with old creds), then the new creds.
14421510
assert_eq!(

0 commit comments

Comments
 (0)