Skip to content

Commit 4a61504

Browse files
crus-umichCaitlin Russell
and
Caitlin Russell
authored
Adding cache refresh logic with new URL RefreshNow parameter (#65)
*Issue #, if available:* Closes 12 *Description of changes:* Supports new refreshNow parameter in URL requests to the agent, setting this parameter to true will call Secrets Manger to get latest value of secret By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: Caitlin Russell <caitarus@amazon.com>
1 parent ddda791 commit 4a61504

File tree

6 files changed

+384
-31
lines changed

6 files changed

+384
-31
lines changed

README.md

+88
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,95 @@ def get_secret():
345345
# Handle network errors
346346
raise Exception(f"Error: {e}")
347347
```
348+
------
349+
350+
**Force-refresh secrets with `RefreshNow`**
351+
352+
Learn how to use the refreshNow parameter to force the Secrets Manager Agent (SMA) to refresh secret values.
353+
354+
Secrets Manager Agent uses an in-memory cache to store secret values, which it refreshes periodically. By default, this refresh occurs when you request a secret after the Time to Live (TTL) has expired, typically every 300 seconds. However, this approach can sometimes result in stale secret values, especially if a secret rotates before the cache entry expires.
355+
356+
To address this limitation, Secrets Manager Agent supports a parameter called `refreshNow` in the URL. You can use this parameter to force an immediate refresh of a secret's value, bypassing the cache and ensuring you have the most up-to-date information.
357+
358+
Default behavior (without `refreshNow`):
359+
- Uses cached values until TTL expires
360+
- Refreshes secrets only after TTL (default 300 seconds)
361+
- May return stale values if secrets rotate before the cache expires
362+
363+
Behavior with `refreshNow=true`:
364+
- Bypasses the cache entirely
365+
- Retrieves the latest secret value directly from Secrets Manager
366+
- Updates the cache with the fresh value and resets the TTL
367+
- Ensures you always get the most current secret value
368+
369+
By using the `refreshNow` parameter, you can ensure that you're always working with the most current secret values, even in scenarios where frequent secret rotation is necessary.
370+
371+
## `refreshNow` parameter behavior
372+
373+
`refreshNow` set to `true`:
374+
- If Secrets Manager Agent can't retrieve the secret from Secrets Manager, it returns an error and does not update the cache.
375+
376+
`refreshNow` set to `false` or not specified:
377+
- Secrets Manager Agent follows its default behavior:
378+
- If the cached value is fresher than the TTL, Secrets Manager Agent returns the cached value.
379+
- If the cached value is older than the TTL, Secrets Manager Agent makes a call to Secrets Manager.
380+
381+
## Using the refreshNow parameter
382+
383+
To use the `refreshNow` parameter, include it in the URL for the Secrets Manager Agent GET request.
384+
385+
### Example - Secrets Manager Agent GET request with refreshNow parameter
386+
387+
> **Important**: The default value of `refreshNow` is `false`. When set to `true`, it overrides the TTL specified in the Secrets Manager Agent configuration file and makes an API call to Secrets Manager.
388+
389+
#### [ curl ]
390+
391+
The following curl example shows how force Secrets Manager Agent to refresh the secret. The example relies on the SSRF being present in a file, which is where it is stored by the install script.
392+
393+
```bash
394+
curl -v -H \
395+
"X-Aws-Parameters-Secrets-Token: $(</var/run/awssmatoken)" \
396+
'http://localhost:2773/secretsmanager/get?secretId=<YOUR_SECRET_ID>&refreshNow=true' \
397+
echo
398+
```
399+
400+
#### [ Python ]
401+
402+
The following Python example shows how to get a secret from the Secrets Manager Agent. The example relies on the SSRF being present in a file, which is where it is stored by the install script.
403+
404+
```python
405+
import requests
406+
import json
348407

408+
# Function that fetches the secret from Secrets Manager Agent for the provided secret id.
409+
def get_secret():
410+
# Construct the URL for the GET request
411+
url = f"http://localhost:2773/secretsmanager/get?secretId=<YOUR_SECRET_ID>&refreshNow=true"
412+
413+
# Get the SSRF token from the token file
414+
with open('/var/run/awssmatoken') as fp:
415+
token = fp.read()
416+
417+
headers = {
418+
"X-Aws-Parameters-Secrets-Token": token.strip()
419+
}
420+
421+
try:
422+
# Send the GET request with headers
423+
response = requests.get(url, headers=headers)
424+
425+
# Check if the request was successful
426+
if response.status_code == 200:
427+
# Return the secret value
428+
return response.text
429+
else:
430+
# Handle error cases
431+
raise Exception(f"Status code {response.status_code} - {response.text}")
432+
433+
except Exception as e:
434+
# Handle network errors
435+
raise Exception(f"Error: {e}")
436+
```
349437
------
350438

351439
## Configure the Secrets Manager Agent<a name="secrets-manager-agent-config"></a>

aws_secretsmanager_agent/src/cache_manager.rs

+20-3
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ impl CacheManager {
4444
/// * `name` - The name of the secret to fetch.
4545
/// * `version` - The version of the secret to fetch.
4646
/// * `label` - The label of the secret to fetch.
47+
/// * `refresh_now` - Whether to serve from the cache or fetch from ASM.
4748
///
4849
/// # Returns
4950
///
@@ -65,9 +66,14 @@ impl CacheManager {
6566
secret_id: &str,
6667
version: Option<&str>,
6768
label: Option<&str>,
69+
refresh_now: bool,
6870
) -> Result<String, HttpError> {
6971
// Read the secret from the cache or fetch it over the network.
70-
let found = match self.0.get_secret_value(secret_id, version, label).await {
72+
let found = match self
73+
.0
74+
.get_secret_value(secret_id, version, label, refresh_now)
75+
.await
76+
{
7177
Ok(value) => value,
7278
Err(e) if e.is::<SdkError<GetSecretValueError, HttpResponse>>() => {
7379
let (code, msg, status) = svc_err::<GetSecretValueError>(e)?;
@@ -154,10 +160,10 @@ pub mod tests {
154160
use aws_sdk_secretsmanager as secretsmanager;
155161
use aws_smithy_runtime::client::http::test_util::{infallible_client_fn, NeverClient};
156162
use aws_smithy_types::body::SdkBody;
157-
use core::time::Duration;
158163
use http::{Request, Response};
159164
use serde_json::Value;
160165
use std::thread::sleep;
166+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
161167

162168
use std::cell::RefCell;
163169
use std::thread_local;
@@ -166,13 +172,14 @@ pub mod tests {
166172
"arn:aws:secretsmanager:us-west-2:123456789012:secret:{{name}}-NhBWsc";
167173
pub const DEFAULT_VERSION: &str = "5767290c-d089-49ed-b97c-17086f8c9d79";
168174
pub const DEFAULT_LABEL: &str = "AWSCURRENT";
175+
pub const DEFAULT_SECRET_STRING: &str = "hunter2";
169176

170177
// Template GetSecretValue responses for testing
171178
const GSV_BODY: &str = r###"{
172179
"ARN": "{{arn}}",
173180
"Name": "{{name}}",
174181
"VersionId": "{{version}}",
175-
"SecretString": "hunter2",
182+
"SecretString": "{{secret}}",
176183
"VersionStages": [
177184
"{{label}}"
178185
],
@@ -248,6 +255,15 @@ pub mod tests {
248255
.map_or(DEFAULT_LABEL, |x| x.as_str().unwrap());
249256
let name = req_map.get("SecretId").unwrap().as_str().unwrap(); // Does not handle full ARN case.
250257

258+
let secret_string = match name {
259+
secret if secret.starts_with("REFRESHNOW") => SystemTime::now()
260+
.duration_since(UNIX_EPOCH)
261+
.unwrap()
262+
.as_millis()
263+
.to_string(),
264+
_ => DEFAULT_SECRET_STRING.to_string(),
265+
};
266+
251267
let (code, template) = match parts.headers["x-amz-target"].to_str().unwrap() {
252268
"secretsmanager.GetSecretValue" if name.starts_with("KMSACCESSDENIED") => {
253269
(400, KMS_ACCESS_DENIED_BODY)
@@ -276,6 +292,7 @@ pub mod tests {
276292
.replace("{{arn}}", FAKE_ARN)
277293
.replace("{{name}}", name)
278294
.replace("{{version}}", version)
295+
.replace("{{secret}}", &secret_string)
279296
.replace("{{label}}", label);
280297
(code, rsp)
281298
}

aws_secretsmanager_agent/src/main.rs

+54-3
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,9 @@ mod tests {
428428
assert_eq!(map.get("Name").unwrap(), name);
429429
assert_eq!(map.get("ARN").unwrap(), &fake_arn);
430430
assert_eq!(map.get("VersionId").unwrap(), version);
431-
assert_eq!(map.get("SecretString").unwrap(), "hunter2");
431+
if !name.contains("REFRESHNOW") {
432+
assert_eq!(map.get("SecretString").unwrap(), "hunter2");
433+
}
432434
assert_eq!(map.get("CreatedDate").unwrap(), "1569534789.046");
433435
assert_eq!(
434436
map.get("VersionStages").unwrap().as_array().unwrap(),
@@ -622,6 +624,14 @@ mod tests {
622624
validate_response("MyTest", body);
623625
}
624626

627+
// Verify a query using the refreshNow parameter
628+
#[tokio::test]
629+
async fn basic_refresh_success() {
630+
let (status, body) = run_request("/secretsmanager/get?secretId=MyTest&refreshNow=1").await;
631+
assert_eq!(status, StatusCode::OK);
632+
validate_response("MyTest", body);
633+
}
634+
625635
// Verify a query using the pending label
626636
#[tokio::test]
627637
async fn pending_success() {
@@ -646,7 +656,7 @@ mod tests {
646656
async fn all_args_success() {
647657
let ver = "000000000000";
648658
let req =
649-
format!("/secretsmanager/get?secretId=MyTest&versionStage=AWSPENDING&versionId={ver}");
659+
format!("/secretsmanager/get?secretId=MyTest&versionStage=AWSPENDING&versionId={ver}&refreshNow=true");
650660
let (status, body) = run_request(&req).await;
651661
assert_eq!(status, StatusCode::OK);
652662
validate_response_extra("MyTest", ver, vec!["AWSPENDING"], body);
@@ -676,6 +686,38 @@ mod tests {
676686
);
677687
}
678688

689+
// Verify refreshNow behavior
690+
#[tokio::test]
691+
async fn refresh_now_test() {
692+
let responses = run_requests_with_client(
693+
vec![
694+
("GET", "/secretsmanager/get?secretId=REFRESHNOWtestsecret"),
695+
("GET", "/secretsmanager/get?secretId=REFRESHNOWtestsecret"),
696+
(
697+
"GET",
698+
"/secretsmanager/get?secretId=REFRESHNOWtestsecret&refreshNow=true",
699+
),
700+
],
701+
vec![("X-Aws-Parameters-Secrets-Token", "xyzzy")],
702+
None,
703+
)
704+
.await
705+
.unwrap();
706+
707+
let mut secret_strings = Vec::new();
708+
for (status, body) in responses {
709+
assert_eq!(status, StatusCode::OK);
710+
711+
let map: serde_json::Map<String, Value> = serde_json::from_slice(&body).unwrap();
712+
let secret_string = map.get("SecretString").unwrap().to_string();
713+
714+
secret_strings.insert(0, secret_string)
715+
}
716+
717+
assert_ne!(secret_strings[1], secret_strings[2]);
718+
assert_eq!(secret_strings[0], secret_strings[1]);
719+
}
720+
679721
// Verify a basic path based request with an alternate header succeeds
680722
#[tokio::test]
681723
async fn path_success() {
@@ -700,6 +742,15 @@ mod tests {
700742
validate_response_extra("My/Test", DEFAULT_VERSION, vec!["AWSPENDING"], body);
701743
}
702744

745+
// Verify a query using the refreshNow parameter
746+
#[tokio::test]
747+
async fn path_refresh_success() {
748+
let req = "/v1/My/Test?versionStage=AWSPENDING&refreshNow=0";
749+
let (status, body) = run_request(&req).await;
750+
assert_eq!(status, StatusCode::OK);
751+
validate_response_extra("My/Test", DEFAULT_VERSION, vec!["AWSPENDING"], body);
752+
}
753+
703754
// Verify a query for a specific version.
704755
#[tokio::test]
705756
async fn path_version_success() {
@@ -714,7 +765,7 @@ mod tests {
714765
#[tokio::test]
715766
async fn path_all_args_success() {
716767
let ver = "000000000000";
717-
let req = format!("/v1/My/Test?versionStage=AWSPENDING&versionId={ver}");
768+
let req = format!("/v1/My/Test?versionStage=AWSPENDING&versionId={ver}&refreshNow=true");
718769
let (status, body) = run_request(&req).await;
719770
assert_eq!(status, StatusCode::OK);
720771
validate_response_extra("My/Test", ver, vec!["AWSPENDING"], body);

aws_secretsmanager_agent/src/parse.rs

+78
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,20 @@ pub(crate) struct GSVQuery {
99
pub secret_id: String,
1010
pub version_id: Option<String>,
1111
pub version_stage: Option<String>,
12+
pub refresh_now: bool,
1213
}
1314

1415
impl GSVQuery {
16+
fn parse_refresh_value(s: &str) -> Result<bool, HttpError> {
17+
match s.to_lowercase().as_str() {
18+
"true" => Ok(true),
19+
"1" => Ok(true),
20+
"false" => Ok(false),
21+
"0" => Ok(false),
22+
_ => Err(HttpError(400, "invalid refreshNow value".to_string())),
23+
}
24+
}
25+
1526
pub(crate) fn try_from_query(s: &str) -> Result<Self, HttpError> {
1627
// url library can only parse complete URIs. The host/port/scheme used is irrelevant since it is not used
1728
let complete_uri = format!("http://localhost{}", s);
@@ -22,13 +33,15 @@ impl GSVQuery {
2233
secret_id: "".into(),
2334
version_id: None,
2435
version_stage: None,
36+
refresh_now: false,
2537
};
2638

2739
for (k, v) in url.query_pairs() {
2840
match k.borrow() {
2941
"secretId" => query.secret_id = v.into(),
3042
"versionId" => query.version_id = Some(v.into()),
3143
"versionStage" => query.version_stage = Some(v.into()),
44+
"refreshNow" => query.refresh_now = GSVQuery::parse_refresh_value(&v)?,
3245
p => return Err(HttpError(400, format!("unknown parameter: {}", p))),
3346
}
3447
}
@@ -55,12 +68,14 @@ impl GSVQuery {
5568
secret_id,
5669
version_id: None,
5770
version_stage: None,
71+
refresh_now: false,
5872
};
5973

6074
for (k, v) in url.query_pairs() {
6175
match k.borrow() {
6276
"versionId" => query.version_id = Some(v.into()),
6377
"versionStage" => query.version_stage = Some(v.into()),
78+
"refreshNow" => query.refresh_now = GSVQuery::parse_refresh_value(&v)?,
6479
p => return Err(HttpError(400, format!("unknown parameter: {}", p))),
6580
}
6681
}
@@ -83,6 +98,69 @@ mod tests {
8398
assert_eq!(query.secret_id, secret_id);
8499
assert_eq!(query.version_id, None);
85100
assert_eq!(query.version_stage, None);
101+
assert_eq!(query.refresh_now, false);
102+
}
103+
104+
#[test]
105+
fn parse_query_refresh() {
106+
let secret_id = "MyTest".to_owned();
107+
let query = GSVQuery::try_from_query(&format!(
108+
"/secretsmanager/get?secretId={}&refreshNow={}",
109+
secret_id, true
110+
))
111+
.unwrap();
112+
113+
assert_eq!(query.secret_id, secret_id);
114+
assert_eq!(query.version_id, None);
115+
assert_eq!(query.version_stage, None);
116+
assert_eq!(query.refresh_now, true);
117+
}
118+
119+
#[test]
120+
fn parse_query_refresh_false() {
121+
let secret_id = "MyTest".to_owned();
122+
let query = GSVQuery::try_from_query(&format!(
123+
"/secretsmanager/get?secretId={}&refreshNow={}",
124+
secret_id, "0"
125+
))
126+
.unwrap();
127+
128+
assert_eq!(query.secret_id, secret_id);
129+
assert_eq!(query.version_id, None);
130+
assert_eq!(query.version_stage, None);
131+
assert_eq!(query.refresh_now, false);
132+
}
133+
134+
#[test]
135+
fn parse_refresh_invalid_parameter() {
136+
let secret_id = "MyTest".to_owned();
137+
let version_id = "myversion".to_owned();
138+
let version_stage = "dev".to_owned();
139+
match GSVQuery::try_from_query(&format!(
140+
"/secretsmanager/get?secretId={}&versionId={}&versionStage={}&refreshNow=123",
141+
secret_id, version_id, version_stage
142+
)) {
143+
Ok(_) => panic!("should not parse"),
144+
Err(e) => {
145+
assert_eq!(e.0, 400);
146+
assert_eq!(e.1, "invalid refreshNow value");
147+
}
148+
}
149+
}
150+
151+
#[test]
152+
fn parse_refresh_case_insensitive() {
153+
let secret_id = "MyTest".to_owned();
154+
let query = GSVQuery::try_from_query(&format!(
155+
"/secretsmanager/get?secretId={}&refreshNow={}",
156+
secret_id, "FALSE"
157+
))
158+
.unwrap();
159+
160+
assert_eq!(query.secret_id, secret_id);
161+
assert_eq!(query.version_id, None);
162+
assert_eq!(query.version_stage, None);
163+
assert_eq!(query.refresh_now, false);
86164
}
87165

88166
#[test]

0 commit comments

Comments
 (0)