use crate::error::ProxyError;
use crate::types::{Action, ResolvedIdentity, S3Operation};
fn key_matches_prefix(key: &str, prefix: &str) -> bool {
if prefix.ends_with('/') || prefix.is_empty() {
return key.starts_with(prefix);
}
key == prefix
|| (key.len() > prefix.len()
&& key.starts_with(prefix)
&& key.as_bytes()[prefix.len()] == b'/')
}
pub fn authorize(
identity: &ResolvedIdentity,
operation: &S3Operation,
bucket_config: &crate::types::BucketConfig,
) -> Result<(), ProxyError> {
if matches!(identity, ResolvedIdentity::Anonymous) {
if bucket_config.anonymous_access {
let action = operation.action();
if matches!(
action,
Action::GetObject | Action::HeadObject | Action::ListBucket
) {
return Ok(());
}
}
return Err(ProxyError::AccessDenied);
}
let scopes = match identity {
ResolvedIdentity::Anonymous => unreachable!(),
ResolvedIdentity::Authenticated(id) => &id.allowed_scopes,
};
let action = operation.action();
let bucket = operation.bucket().unwrap_or_default().to_string();
let key = match operation {
S3Operation::ListBucket { raw_query, .. } => {
raw_query
.as_deref()
.and_then(|q| {
url::form_urlencoded::parse(q.as_bytes())
.find(|(k, _)| k == "prefix")
.map(|(_, v)| v.to_string())
})
.unwrap_or_default()
}
_ => operation.key().to_string(),
};
let ignore_prefix = matches!(operation, S3Operation::DeleteObjects { .. });
let authorized = scopes.iter().any(|scope| {
if scope.bucket != bucket {
return false;
}
if !scope.actions.contains(&action) {
return false;
}
if ignore_prefix || scope.prefixes.is_empty() {
return true; }
scope
.prefixes
.iter()
.any(|prefix| key_matches_prefix(&key, prefix))
});
if authorized {
Ok(())
} else {
tracing::warn!(
action = ?action,
bucket = %bucket,
key = %key,
scopes = ?scopes,
"authorization denied — no scope grants access"
);
Err(ProxyError::AccessDenied)
}
}
pub fn key_authorized(
identity: &ResolvedIdentity,
bucket: &str,
action: Action,
key: &str,
) -> bool {
let scopes = match identity {
ResolvedIdentity::Anonymous => return false,
ResolvedIdentity::Authenticated(id) => &id.allowed_scopes,
};
scopes.iter().any(|scope| {
scope.bucket == bucket
&& scope.actions.contains(&action)
&& (scope.prefixes.is_empty()
|| scope.prefixes.iter().any(|p| key_matches_prefix(key, p)))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefix_with_slash_matches_children() {
assert!(key_matches_prefix("data/file.txt", "data/"));
assert!(key_matches_prefix("data/sub/file.txt", "data/"));
}
#[test]
fn prefix_without_slash_enforces_boundary() {
assert!(key_matches_prefix("data/file.txt", "data"));
assert!(key_matches_prefix("data", "data"));
assert!(!key_matches_prefix("data-private/secret.txt", "data"));
assert!(!key_matches_prefix("database/dump.sql", "data"));
}
#[test]
fn empty_prefix_matches_everything() {
assert!(key_matches_prefix("anything/at/all.txt", ""));
assert!(key_matches_prefix("", ""));
}
#[test]
fn prefix_no_match() {
assert!(!key_matches_prefix("other/file.txt", "data/"));
assert!(!key_matches_prefix("other/file.txt", "data"));
}
use crate::types::{AccessScope, AuthenticatedIdentity, BucketConfig};
fn identity_with(scope: AccessScope) -> ResolvedIdentity {
ResolvedIdentity::Authenticated(AuthenticatedIdentity {
principal_name: "tester".into(),
allowed_scopes: vec![scope],
})
}
fn bucket(name: &str, anonymous: bool) -> BucketConfig {
BucketConfig {
name: name.into(),
backend_type: "s3".into(),
backend_prefix: None,
anonymous_access: anonymous,
allowed_roles: vec![],
backend_options: Default::default(),
}
}
#[test]
fn key_authorized_enforces_prefix_per_key() {
let id = identity_with(AccessScope {
bucket: "b".into(),
prefixes: vec!["data/".into()],
actions: vec![Action::DeleteObject],
});
assert!(key_authorized(&id, "b", Action::DeleteObject, "data/x.txt"));
assert!(!key_authorized(
&id,
"b",
Action::DeleteObject,
"other/x.txt"
));
assert!(!key_authorized(
&id,
"other",
Action::DeleteObject,
"data/x.txt"
));
assert!(!key_authorized(&id, "b", Action::PutObject, "data/x.txt"));
}
#[test]
fn key_authorized_denies_anonymous() {
assert!(!key_authorized(
&ResolvedIdentity::Anonymous,
"b",
Action::DeleteObject,
"anything"
));
}
#[test]
fn delete_objects_coarse_authz_ignores_prefix() {
let id = identity_with(AccessScope {
bucket: "b".into(),
prefixes: vec!["data/".into()],
actions: vec![Action::DeleteObject],
});
let op = S3Operation::DeleteObjects { bucket: "b".into() };
assert!(authorize(&id, &op, &bucket("b", false)).is_ok());
}
#[test]
fn delete_objects_denied_without_delete_action() {
let id = identity_with(AccessScope {
bucket: "b".into(),
prefixes: vec![],
actions: vec![Action::GetObject],
});
let op = S3Operation::DeleteObjects { bucket: "b".into() };
assert!(authorize(&id, &op, &bucket("b", false)).is_err());
}
#[test]
fn delete_objects_denied_for_anonymous() {
let op = S3Operation::DeleteObjects { bucket: "b".into() };
assert!(authorize(&ResolvedIdentity::Anonymous, &op, &bucket("b", true)).is_err());
}
}