use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Serialize)]
pub struct BucketOwner {
#[serde(rename = "ID")]
pub id: String,
#[serde(rename = "DisplayName")]
pub display_name: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct BucketConfig {
pub name: String,
pub backend_type: String,
pub backend_prefix: Option<String>,
pub anonymous_access: bool,
#[serde(default)]
pub allowed_roles: Vec<String>,
#[serde(default)]
pub backend_options: HashMap<String, String>,
}
const REDACTED_OPTION_KEYS: &[&str] = &[
"secret_access_key",
"access_key",
"service_account_key",
"token",
];
impl fmt::Debug for BucketConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let redacted_opts: HashMap<&str, &str> = self
.backend_options
.iter()
.map(|(k, v)| {
let val = if REDACTED_OPTION_KEYS.contains(&k.as_str()) {
"[REDACTED]"
} else {
v.as_str()
};
(k.as_str(), val)
})
.collect();
f.debug_struct("BucketConfig")
.field("name", &self.name)
.field("backend_type", &self.backend_type)
.field("backend_prefix", &self.backend_prefix)
.field("anonymous_access", &self.anonymous_access)
.field("allowed_roles", &self.allowed_roles)
.field("backend_options", &redacted_opts)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendType {
S3,
Azure,
Gcs,
}
impl BucketConfig {
pub fn parsed_backend_type(&self) -> Option<BackendType> {
match self.backend_type.as_str() {
"s3" => Some(BackendType::S3),
"az" | "azure" => Some(BackendType::Azure),
"gcs" | "gs" => Some(BackendType::Gcs),
_ => None,
}
}
pub fn is_s3_backend(&self) -> bool {
matches!(self.parsed_backend_type(), Some(BackendType::S3))
}
pub fn option(&self, key: &str) -> Option<&str> {
self.backend_options.get(key).map(|s| s.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleConfig {
pub role_id: String,
pub name: String,
#[serde(default)]
pub trusted_oidc_issuers: Vec<String>,
#[serde(
default,
alias = "required_audience",
deserialize_with = "deserialize_audiences"
)]
pub required_audiences: Vec<String>,
#[serde(default)]
pub subject_conditions: Vec<String>,
#[serde(default)]
pub allowed_scopes: Vec<AccessScope>,
pub max_session_duration_secs: u64,
}
fn deserialize_audiences<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
Ok(match Option::<OneOrMany>::deserialize(deserializer)? {
None => vec![],
Some(OneOrMany::One(s)) => vec![s],
Some(OneOrMany::Many(v)) => v,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessScope {
pub bucket: String,
pub prefixes: Vec<String>,
pub actions: Vec<Action>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
GetObject,
HeadObject,
PutObject,
ListBucket,
CreateMultipartUpload,
UploadPart,
CompleteMultipartUpload,
AbortMultipartUpload,
DeleteObject,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct StoredCredential {
pub access_key_id: String,
pub secret_access_key: String,
pub principal_name: String,
pub allowed_scopes: Vec<AccessScope>,
pub created_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
pub enabled: bool,
}
impl fmt::Debug for StoredCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StoredCredential")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"[REDACTED]")
.field("principal_name", &self.principal_name)
.field("allowed_scopes", &self.allowed_scopes)
.field("created_at", &self.created_at)
.field("expires_at", &self.expires_at)
.field("enabled", &self.enabled)
.finish()
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct TemporaryCredentials {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: String,
pub expiration: DateTime<Utc>,
pub allowed_scopes: Vec<AccessScope>,
pub assumed_role_id: String,
pub source_identity: String,
}
impl fmt::Debug for TemporaryCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TemporaryCredentials")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"[REDACTED]")
.field("session_token", &"[REDACTED]")
.field("expiration", &self.expiration)
.field("allowed_scopes", &self.allowed_scopes)
.field("assumed_role_id", &self.assumed_role_id)
.field("source_identity", &self.source_identity)
.finish()
}
}
#[derive(Clone)]
pub struct BackendCredentials {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: String,
pub expiration: DateTime<Utc>,
}
impl BackendCredentials {
pub fn apply_to(&self, config: &mut BucketConfig) {
let opts = &mut config.backend_options;
opts.insert("access_key_id".to_string(), self.access_key_id.clone());
opts.insert(
"secret_access_key".to_string(),
self.secret_access_key.clone(),
);
opts.insert("token".to_string(), self.session_token.clone());
opts.remove("skip_signature");
}
}
impl fmt::Debug for BackendCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BackendCredentials")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"[REDACTED]")
.field("session_token", &"[REDACTED]")
.field("expiration", &self.expiration)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct AuthenticatedIdentity {
pub principal_name: String,
pub allowed_scopes: Vec<AccessScope>,
}
#[derive(Debug, Clone)]
pub enum ResolvedIdentity {
Anonymous,
Authenticated(AuthenticatedIdentity),
}
#[derive(Debug, Clone)]
pub enum S3Operation {
GetObject {
bucket: String,
key: String,
},
HeadObject {
bucket: String,
key: String,
},
PutObject {
bucket: String,
key: String,
},
CreateMultipartUpload {
bucket: String,
key: String,
},
UploadPart {
bucket: String,
key: String,
upload_id: String,
part_number: u32,
},
CompleteMultipartUpload {
bucket: String,
key: String,
upload_id: String,
},
AbortMultipartUpload {
bucket: String,
key: String,
upload_id: String,
},
DeleteObject {
bucket: String,
key: String,
},
DeleteObjects {
bucket: String,
},
ListBucket {
bucket: String,
raw_query: Option<String>,
},
ListBuckets,
}
impl S3Operation {
pub fn method(&self) -> http::Method {
match self {
S3Operation::GetObject { .. }
| S3Operation::ListBucket { .. }
| S3Operation::ListBuckets => http::Method::GET,
S3Operation::HeadObject { .. } => http::Method::HEAD,
S3Operation::PutObject { .. } | S3Operation::UploadPart { .. } => http::Method::PUT,
S3Operation::DeleteObject { .. } | S3Operation::AbortMultipartUpload { .. } => {
http::Method::DELETE
}
S3Operation::CreateMultipartUpload { .. }
| S3Operation::CompleteMultipartUpload { .. }
| S3Operation::DeleteObjects { .. } => http::Method::POST,
}
}
pub fn action(&self) -> Action {
match self {
S3Operation::GetObject { .. } => Action::GetObject,
S3Operation::HeadObject { .. } => Action::HeadObject,
S3Operation::PutObject { .. } => Action::PutObject,
S3Operation::ListBucket { .. } => Action::ListBucket,
S3Operation::CreateMultipartUpload { .. } => Action::CreateMultipartUpload,
S3Operation::UploadPart { .. } => Action::UploadPart,
S3Operation::CompleteMultipartUpload { .. } => Action::CompleteMultipartUpload,
S3Operation::AbortMultipartUpload { .. } => Action::AbortMultipartUpload,
S3Operation::DeleteObject { .. } => Action::DeleteObject,
S3Operation::DeleteObjects { .. } => Action::DeleteObject,
S3Operation::ListBuckets => Action::ListBucket,
}
}
pub fn bucket(&self) -> Option<&str> {
match self {
S3Operation::GetObject { bucket, .. }
| S3Operation::HeadObject { bucket, .. }
| S3Operation::PutObject { bucket, .. }
| S3Operation::ListBucket { bucket, .. }
| S3Operation::CreateMultipartUpload { bucket, .. }
| S3Operation::UploadPart { bucket, .. }
| S3Operation::CompleteMultipartUpload { bucket, .. }
| S3Operation::AbortMultipartUpload { bucket, .. }
| S3Operation::DeleteObject { bucket, .. }
| S3Operation::DeleteObjects { bucket } => Some(bucket),
S3Operation::ListBuckets => None,
}
}
pub fn key(&self) -> &str {
match self {
S3Operation::GetObject { key, .. }
| S3Operation::HeadObject { key, .. }
| S3Operation::PutObject { key, .. }
| S3Operation::CreateMultipartUpload { key, .. }
| S3Operation::UploadPart { key, .. }
| S3Operation::CompleteMultipartUpload { key, .. }
| S3Operation::AbortMultipartUpload { key, .. }
| S3Operation::DeleteObject { key, .. } => key,
S3Operation::ListBucket { .. }
| S3Operation::ListBuckets
| S3Operation::DeleteObjects { .. } => "",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action() {
let op = S3Operation::GetObject {
bucket: "b".into(),
key: "k".into(),
};
assert_eq!(op.action(), Action::GetObject);
let op = S3Operation::PutObject {
bucket: "b".into(),
key: "k".into(),
};
assert_eq!(op.action(), Action::PutObject);
let op = S3Operation::ListBucket {
bucket: "b".into(),
raw_query: None,
};
assert_eq!(op.action(), Action::ListBucket);
assert_eq!(S3Operation::ListBuckets.action(), Action::ListBucket);
let op = S3Operation::DeleteObject {
bucket: "b".into(),
key: "k".into(),
};
assert_eq!(op.action(), Action::DeleteObject);
}
#[test]
fn test_bucket() {
let op = S3Operation::GetObject {
bucket: "my-bucket".into(),
key: "k".into(),
};
assert_eq!(op.bucket(), Some("my-bucket"));
assert_eq!(S3Operation::ListBuckets.bucket(), None);
}
#[test]
fn test_key() {
let op = S3Operation::GetObject {
bucket: "b".into(),
key: "my/key.txt".into(),
};
assert_eq!(op.key(), "my/key.txt");
let op = S3Operation::ListBucket {
bucket: "b".into(),
raw_query: Some("prefix=foo/".into()),
};
assert_eq!(op.key(), "");
assert_eq!(S3Operation::ListBuckets.key(), "");
}
fn anon_s3_bucket() -> BucketConfig {
use std::collections::HashMap;
let mut backend_options = HashMap::new();
backend_options.insert("bucket_name".to_string(), "my-bucket".to_string());
backend_options.insert("region".to_string(), "us-west-2".to_string());
backend_options.insert("skip_signature".to_string(), "true".to_string());
BucketConfig {
name: "acct:product".to_string(),
backend_type: "s3".to_string(),
backend_prefix: None,
anonymous_access: true,
allowed_roles: vec![],
backend_options,
}
}
#[test]
fn backend_credentials_apply_to_signs_the_bucket() {
use chrono::{TimeZone, Utc};
let creds = BackendCredentials {
access_key_id: "ASIA123".to_string(),
secret_access_key: "secret".to_string(),
session_token: "session".to_string(),
expiration: Utc.with_ymd_and_hms(2026, 6, 3, 4, 13, 40).unwrap(),
};
let mut config = anon_s3_bucket();
creds.apply_to(&mut config);
assert_eq!(config.option("access_key_id"), Some("ASIA123"));
assert_eq!(config.option("secret_access_key"), Some("secret"));
assert_eq!(config.option("token"), Some("session"));
assert_eq!(config.option("skip_signature"), None);
assert!(config.anonymous_access);
assert_eq!(config.option("bucket_name"), Some("my-bucket"));
}
#[test]
fn backend_credentials_bucket_debug_redacts_applied_secrets() {
use chrono::{TimeZone, Utc};
let creds = BackendCredentials {
access_key_id: "ASIA123".to_string(),
secret_access_key: "super-secret".to_string(),
session_token: "super-session".to_string(),
expiration: Utc.with_ymd_and_hms(2026, 6, 3, 4, 13, 40).unwrap(),
};
let mut config = anon_s3_bucket();
creds.apply_to(&mut config);
let dbg = format!("{config:?}");
assert!(!dbg.contains("super-secret"));
assert!(!dbg.contains("super-session"));
}
fn parse_role(audience_field: &str) -> Result<RoleConfig, serde_json::Error> {
serde_json::from_str(&format!(
r#"{{"role_id":"r","name":"R","max_session_duration_secs":3600{audience_field}}}"#
))
}
#[test]
fn audiences_accepts_legacy_single_string() {
let r = parse_role(r#","required_audience":"x""#).unwrap();
assert_eq!(r.required_audiences, vec!["x".to_string()]);
}
#[test]
fn audiences_accepts_single_string_and_list() {
assert_eq!(
parse_role(r#","required_audiences":"x""#)
.unwrap()
.required_audiences,
vec!["x".to_string()]
);
assert_eq!(
parse_role(r#","required_audiences":["x","y"]"#)
.unwrap()
.required_audiences,
vec!["x".to_string(), "y".to_string()]
);
}
#[test]
fn audiences_absent_or_null_is_unrestricted() {
assert!(parse_role("").unwrap().required_audiences.is_empty());
assert!(parse_role(r#","required_audiences":null"#)
.unwrap()
.required_audiences
.is_empty());
assert!(parse_role(r#","required_audience":null"#)
.unwrap()
.required_audiences
.is_empty());
}
#[test]
fn audiences_both_keys_is_an_error() {
assert!(parse_role(r#","required_audience":"x","required_audiences":["y"]"#).is_err());
}
}