use chrono::{Duration, Utc};
use multistore::types::{AccessScope, RoleConfig, TemporaryCredentials};
use rand::RngCore;
fn resolve_scopes(scopes: &[AccessScope], claims: &serde_json::Value) -> Vec<AccessScope> {
scopes
.iter()
.map(|scope| {
let bucket = resolve_template(&scope.bucket, claims);
let prefixes = scope
.prefixes
.iter()
.map(|p| resolve_template(p, claims))
.collect();
AccessScope {
bucket,
prefixes,
actions: scope.actions.clone(),
}
})
.collect()
}
fn resolve_template(template: &str, claims: &serde_json::Value) -> String {
let mut result = template.to_string();
while let Some(start) = result.find('{') {
if let Some(end) = result[start..].find('}') {
let end = start + end;
let key = &result[start + 1..end];
let value = claims.get(key).and_then(|v| v.as_str()).unwrap_or("");
result = format!("{}{}{}", &result[..start], value, &result[end + 1..]);
} else {
break;
}
}
result
}
pub fn mint_temporary_credentials(
role: &RoleConfig,
source_identity: &str,
duration_seconds: u64,
key_prefix: &str,
claims: &serde_json::Value,
) -> TemporaryCredentials {
let access_key_id = format!("{}{}", key_prefix, generate_random_id(16));
let secret_access_key = generate_random_id(40);
let session_token = generate_session_token();
let expiration = Utc::now() + Duration::seconds(duration_seconds as i64);
TemporaryCredentials {
access_key_id,
secret_access_key,
session_token,
expiration,
allowed_scopes: resolve_scopes(&role.allowed_scopes, claims),
assumed_role_id: role.role_id.clone(),
source_identity: source_identity.to_string(),
}
}
fn generate_random_id(len: usize) -> String {
use base64::Engine;
let mut bytes = vec![0u8; len];
rand::rngs::OsRng.fill_bytes(&mut bytes);
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
encoded
.chars()
.filter(|c| c.is_alphanumeric())
.take(len)
.collect()
}
fn generate_session_token() -> String {
use base64::Engine;
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use multistore::types::Action;
use serde_json::json;
fn scope(bucket: &str, prefixes: &[&str], actions: &[Action]) -> AccessScope {
AccessScope {
bucket: bucket.to_string(),
prefixes: prefixes.iter().map(|s| s.to_string()).collect(),
actions: actions.to_vec(),
}
}
#[test]
fn resolve_template_in_bucket() {
let scopes = vec![scope("{sub}", &[], &[Action::GetObject])];
let claims = json!({"sub": "alice"});
let resolved = resolve_scopes(&scopes, &claims);
assert_eq!(resolved[0].bucket, "alice");
}
#[test]
fn resolve_template_in_prefix() {
let scopes = vec![scope("my-bucket", &["data/{sub}/"], &[Action::GetObject])];
let claims = json!({"sub": "alice"});
let resolved = resolve_scopes(&scopes, &claims);
assert_eq!(resolved[0].prefixes[0], "data/alice/");
}
#[test]
fn resolve_multiple_claims() {
let scopes = vec![scope("{org}", &["{sub}/"], &[Action::GetObject])];
let claims = json!({"sub": "alice", "org": "acme"});
let resolved = resolve_scopes(&scopes, &claims);
assert_eq!(resolved[0].bucket, "acme");
assert_eq!(resolved[0].prefixes[0], "alice/");
}
#[test]
fn no_templates_unchanged() {
let scopes = vec![scope("static-bucket", &["prefix/"], &[Action::GetObject])];
let claims = json!({"sub": "alice"});
let resolved = resolve_scopes(&scopes, &claims);
assert_eq!(resolved[0].bucket, "static-bucket");
assert_eq!(resolved[0].prefixes[0], "prefix/");
}
#[test]
fn missing_claim_resolves_to_empty() {
let scopes = vec![scope(
"{missing}",
&["{also_missing}/"],
&[Action::GetObject],
)];
let claims = json!({"sub": "alice"});
let resolved = resolve_scopes(&scopes, &claims);
assert_eq!(resolved[0].bucket, "");
assert_eq!(resolved[0].prefixes[0], "/");
}
}