use crate::error::AwsError;
use crate::router::RequestContext;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Arn {
pub partition: String,
pub service: String,
pub region: String,
pub account: String,
pub resource: String,
}
impl fmt::Display for Arn {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"arn:{}:{}:{}:{}:{}",
self.partition, self.service, self.region, self.account, self.resource
)
}
}
pub fn build(ctx: &RequestContext, service: &'static str, resource: impl AsRef<str>) -> String {
format!(
"arn:{}:{}:{}:{}:{}",
ctx.partition,
service,
ctx.region,
ctx.account_id,
resource.as_ref()
)
}
pub fn build_global(
ctx: &RequestContext,
service: &'static str,
resource: impl AsRef<str>,
) -> String {
format!(
"arn:{}:{}::{}:{}",
ctx.partition,
service,
ctx.account_id,
resource.as_ref()
)
}
pub fn build_partition(
ctx: &RequestContext,
service: &'static str,
resource: impl AsRef<str>,
) -> String {
format!("arn:{}:{}:::{}", ctx.partition, service, resource.as_ref())
}
pub fn parse(s: &str) -> Result<Arn, AwsError> {
let mut it = s.splitn(6, ':');
let scheme = it.next();
let partition = it.next();
let service = it.next();
let region = it.next();
let account = it.next();
let resource = it.next();
match (scheme, partition, service, region, account, resource) {
(Some("arn"), Some(p), Some(s), Some(r), Some(a), Some(res))
if !p.is_empty() && !s.is_empty() =>
{
Ok(Arn {
partition: p.to_string(),
service: s.to_string(),
region: r.to_string(),
account: a.to_string(),
resource: res.to_string(),
})
}
_ => Err(AwsError::bad_request(
"InvalidParameterValue",
format!("Malformed ARN: {s}"),
)),
}
}
pub fn expect_owned_by(
arn_str: &str,
ctx: &RequestContext,
region_scope: RegionScope,
) -> Result<Arn, AwsError> {
let parsed = parse(arn_str)?;
if parsed.partition != ctx.partition {
return Err(AwsError::access_denied(format!(
"ARN partition '{}' does not match request partition '{}'.",
parsed.partition, ctx.partition
)));
}
if !parsed.account.is_empty() && parsed.account != ctx.account_id {
return Err(AwsError::access_denied(format!(
"ARN account '{}' does not match request account '{}'.",
parsed.account, ctx.account_id
)));
}
match region_scope {
RegionScope::Global => {
if !parsed.region.is_empty() {
return Err(AwsError::bad_request(
"InvalidParameterValue",
format!(
"ARN region must be empty for global service '{}'.",
parsed.service
),
));
}
}
RegionScope::Regional => {
if !parsed.region.is_empty() && parsed.region != ctx.region {
return Err(AwsError::access_denied(format!(
"ARN region '{}' does not match request region '{}'.",
parsed.region, ctx.region
)));
}
}
}
Ok(parsed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegionScope {
Global,
Regional,
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx_with(partition: &str, region: &str, account: &str) -> RequestContext {
let mut ctx = RequestContext::new_with_account("ec2", region, account);
ctx.partition = partition.to_string();
ctx
}
#[test]
fn build_uses_request_context_segments() {
let ctx = ctx_with("aws", "us-east-1", "111122223333");
assert_eq!(
build(&ctx, "ec2", "instance/i-abc"),
"arn:aws:ec2:us-east-1:111122223333:instance/i-abc"
);
}
#[test]
fn build_honors_non_default_partition() {
let ctx = ctx_with("aws-cn", "cn-north-1", "999988887777");
assert_eq!(
build(&ctx, "s3", "my-bucket"),
"arn:aws-cn:s3:cn-north-1:999988887777:my-bucket"
);
}
#[test]
fn build_honors_govcloud_partition() {
let ctx = ctx_with("aws-us-gov", "us-gov-west-1", "555566667777");
assert_eq!(
build(&ctx, "kms", "key/abc-123"),
"arn:aws-us-gov:kms:us-gov-west-1:555566667777:key/abc-123"
);
}
#[test]
fn build_global_omits_region() {
let ctx = ctx_with("aws", "us-east-1", "111122223333");
assert_eq!(
build_global(&ctx, "iam", "role/AdminRole"),
"arn:aws:iam::111122223333:role/AdminRole"
);
}
#[test]
fn build_partition_omits_region_and_account() {
let ctx = ctx_with("aws", "us-east-1", "111122223333");
assert_eq!(
build_partition(&ctx, "s3", "my-bucket"),
"arn:aws:s3:::my-bucket"
);
}
#[test]
fn parse_round_trips_basic_arn() {
let arn = parse("arn:aws:ec2:us-east-1:111122223333:instance/i-abc").unwrap();
assert_eq!(arn.partition, "aws");
assert_eq!(arn.service, "ec2");
assert_eq!(arn.region, "us-east-1");
assert_eq!(arn.account, "111122223333");
assert_eq!(arn.resource, "instance/i-abc");
assert_eq!(
arn.to_string(),
"arn:aws:ec2:us-east-1:111122223333:instance/i-abc"
);
}
#[test]
fn parse_preserves_colons_in_resource_segment() {
let raw = "arn:aws:logs:us-east-1:111:log-group:/aws/lambda/foo:log-stream:bar";
let arn = parse(raw).unwrap();
assert_eq!(arn.resource, "log-group:/aws/lambda/foo:log-stream:bar");
assert_eq!(arn.to_string(), raw);
}
#[test]
fn parse_accepts_empty_region_and_account_segments() {
let arn = parse("arn:aws:iam::111122223333:role/Admin").unwrap();
assert_eq!(arn.region, "");
assert_eq!(arn.account, "111122223333");
let bucket = parse("arn:aws:s3:::my-bucket").unwrap();
assert_eq!(bucket.region, "");
assert_eq!(bucket.account, "");
assert_eq!(bucket.resource, "my-bucket");
}
#[test]
fn parse_rejects_non_arn_string() {
let err = parse("not-an-arn").unwrap_err();
assert_eq!(err.code, "InvalidParameterValue");
}
#[test]
fn parse_rejects_too_few_segments() {
let err = parse("arn:aws:s3").unwrap_err();
assert_eq!(err.code, "InvalidParameterValue");
}
#[test]
fn parse_rejects_empty_partition_or_service() {
assert!(parse("arn::s3:us-east-1:111:bucket").is_err());
assert!(parse("arn:aws::us-east-1:111:bucket").is_err());
}
#[test]
fn expect_owned_by_accepts_matching_tenant() {
let ctx = ctx_with("aws", "us-east-1", "111122223333");
let arn = "arn:aws:dynamodb:us-east-1:111122223333:table/users";
expect_owned_by(arn, &ctx, RegionScope::Regional).unwrap();
}
#[test]
fn expect_owned_by_rejects_foreign_account() {
let ctx = ctx_with("aws", "us-east-1", "111122223333");
let arn = "arn:aws:dynamodb:us-east-1:999988887777:table/users";
let err = expect_owned_by(arn, &ctx, RegionScope::Regional).unwrap_err();
assert_eq!(err.code, "AccessDeniedException");
}
#[test]
fn expect_owned_by_rejects_foreign_region() {
let ctx = ctx_with("aws", "us-east-1", "111122223333");
let arn = "arn:aws:dynamodb:eu-west-1:111122223333:table/users";
let err = expect_owned_by(arn, &ctx, RegionScope::Regional).unwrap_err();
assert_eq!(err.code, "AccessDeniedException");
}
#[test]
fn expect_owned_by_rejects_foreign_partition() {
let ctx = ctx_with("aws", "us-east-1", "111122223333");
let arn = "arn:aws-cn:dynamodb:us-east-1:111122223333:table/users";
let err = expect_owned_by(arn, &ctx, RegionScope::Regional).unwrap_err();
assert_eq!(err.code, "AccessDeniedException");
}
#[test]
fn expect_owned_by_allows_empty_account_segment() {
let ctx = ctx_with("aws", "us-east-1", "111122223333");
let arn = "arn:aws:s3:::my-bucket";
let parsed = expect_owned_by(arn, &ctx, RegionScope::Regional).unwrap();
assert_eq!(parsed.resource, "my-bucket");
}
#[test]
fn expect_owned_by_global_requires_empty_region() {
let ctx = ctx_with("aws", "us-east-1", "111122223333");
let arn = "arn:aws:iam::111122223333:role/AdminRole";
expect_owned_by(arn, &ctx, RegionScope::Global).unwrap();
let bad = "arn:aws:iam:us-east-1:111122223333:role/AdminRole";
let err = expect_owned_by(bad, &ctx, RegionScope::Global).unwrap_err();
assert_eq!(err.code, "InvalidParameterValue");
}
}