Browse Source

Support AWS standard IAM role ARN formats (issue #7946) (#7948)

* fix(iam): support both AWS standard and legacy IAM role ARN formats

Fix issue #7946 where SeaweedFS only recognized legacy IAM role ARN format
(arn:aws:iam::role/RoleName) but not the standard AWS format with account ID
(arn:aws:iam::ACCOUNT:role/RoleName). This was breaking EKS pod identity
integration which expects the standard format.

Changes:
- Update ExtractRoleNameFromArn() to handle both formats by searching for
  'role/' marker instead of matching a fixed prefix
- Update ExtractRoleNameFromPrincipal() to clearly document both STS and IAM
  formats it supports with or without account ID
- Simplify role ARN validation in validateRoleAssumptionForWebIdentity() and
  validateRoleAssumptionForCredentials() to use the extraction function
- Add comprehensive test coverage with 25 test cases covering both formats

The fix maintains backward compatibility with legacy format while adding
support for standard AWS format with account ID.

Fixes: https://github.com/seaweedfs/seaweedfs/issues/7946

* docs: improve docstring coverage for ARN utility functions

- Add comprehensive package-level documentation
- Enhance ExtractRoleNameFromPrincipal docstring with parameter and return descriptions
- Enhance ExtractRoleNameFromArn docstring with detailed format documentation
- Add docstrings to test functions explaining test coverage
- Update all docstrings to 80%+ coverage for code review compliance

* refactor: improve ARN parsing code maintainability and error messages

- Define constants for ARN prefixes and markers (stsPrefix, stsAssumedRoleMarker, iamPrefix, iamRoleMarker)
- Replace hardcoded magic strings with named constants in ExtractRoleNameFromPrincipal and ExtractRoleNameFromArn
- Enhance error messages in sts_service.go to show expected ARN format when validation fails
- Error message now shows: 'arn:aws:iam::[ACCOUNT_ID:]role/ROLE_NAME' format
- Improves code readability and maintainability
- Facilitates future ARN format changes and debugging

* feat: add structured ARN type for better debugging and extensibility

Implements Option 2 (Structured ARN Type) from ARN handling comparison:

New Features:
- ARNInfo struct with Original, RoleName, AccountID, and Format fields
- ARNFormat enum (Legacy, Standard, Invalid) for type-safe format tracking
- ParseRoleARN() function for structured IAM role ARN parsing
- ParsePrincipalARN() function for structured STS/IAM principal parsing

Benefits:
- Better debugging: Can see original ARN, extracted components, and format type
- Extensible: Easy to add more fields (Region, Service, etc.) in future
- Type-safe: Format is an enum, not a string
- Backward compatible: Kept original string-based functions

STS Service Updates:
- Uses ParseRoleARN() for structured validation
- Logs ARN components at V(4) level for debugging (role, account, format)
- Better error context when validation fails

Test Coverage:
- 7 new tests for ParseRoleARN (legacy, standard, invalid formats)
- 7 new tests for ParsePrincipalARN (STS/IAM, legacy/standard)
- All 39 existing tests still pass
- Total: 53 ARN-related tests

Comparison with MinIO:
- More flexible: Supports both AWS formats (MinIO only supports MinIO format)
- Better tested: 53 tests vs MinIO's 8 tests
- Structured like MinIO but more practical for AWS use cases

* security: fix ARN parsing to prevent malicious ARN acceptance

Fix critical security vulnerability where malicious ARNs could bypass validation:
- ARNs like 'arn:aws:iam::123456789012:user/role/malicious' were incorrectly accepted
- The previous implementation used strings.Index to find 'role/' anywhere in the ARN
- This allowed non-role resource types to be accepted if they contained 'role/' in their path

Changes:
1. Updated ExtractRoleNameFromArn() to validate resource type is exactly 'role/'
2. Updated ExtractRoleNameFromPrincipal() to validate resource type is exactly 'assumed-role/'
3. Updated ParseRoleARN() to validate structure before extracting fields
4. Updated ParsePrincipalARN() to validate structure before extracting fields
5. Added 6 security test cases to prevent regression

The fix validates ARN structure by:
- Splitting on ':' to separate account ID from resource type
- Verifying resource type starts with exact marker ('role/' or 'assumed-role/')
- Only then extracting role name, account ID, and format

All 59 tests pass, including new security tests that verify malicious ARNs are rejected.

Fixes: GitHub Copilot review #3624499048

* test: add test cases for empty role names and improve validation

Address review feedback to improve edge case coverage:

1. Added test case for standard format with empty role name
   - TestExtractRoleNameFromArn: arn:aws:iam::123456789012:role/
   - TestParseRoleARN: arn:aws:iam::123456789012:role/

2. Added empty role name validation for STS ARNs in ParsePrincipalARN
   - Now matches ParseRoleARN behavior
   - Prevents ARNs like arn:aws:sts::assumed-role/ from having valid Format

3. Added test cases for empty STS role names
   - TestParsePrincipalARN: arn:aws:sts::assumed-role/
   - TestParsePrincipalARN: arn:aws:sts::123456789012:assumed-role/

All 65 tests pass (15 for ExtractRoleNameFromArn, 10 for ExtractRoleNameFromPrincipal,
8 for ParseRoleARN, 9 for ParsePrincipalARN, 4 security user ARNs, 2 security STS,
plus existing tests).

* refactor: simplify ARNInfo by removing Format enum

Remove ARNFormat enum (ARNFormatLegacy, ARNFormatStandard, ARNFormatInvalid)
as it's not needed for backward compatibility. Simplifications:

1. Removed ARNFormat type and all format constants
2. Removed Format field from ARNInfo struct
3. Validation now checks if RoleName is empty (simpler and clearer)
4. AccountID presence already distinguishes legacy (empty) from standard (non-empty) formats
5. Updated STS service to check RoleName emptiness instead of Format field
6. Improved debug logging to explicitly show "(legacy format)" or "(standard format)"

Benefits:
- Simpler code with fewer concepts
- AccountID field already provides format information
- Validation is clearer: empty RoleName = invalid ARN
- All 65 tests still pass

This change maintains the same functionality while reducing code complexity.
No backward compatibility concerns as the structured ARN parsing is new.

* test: add comprehensive edge case tests for ARN parsing

Add 4 new test functions covering:
- Multiple role markers in paths (e.g., role/role/name)
- Consecutive slashes in role paths (preserved as valid components)
- Special characters valid in AWS role names (+=,.@-_)
- Extremely long role names near AWS limits

These tests verify the parser's resilience to edge cases and ensure
proper handling of various valid role name formats and special characters.
pull/7959/head
Chris Lu 1 week ago
committed by GitHub
parent
commit
54de32f207
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 36
      weed/iam/sts/sts_service.go
  2. 270
      weed/iam/utils/arn_utils.go
  3. 646
      weed/iam/utils/arn_utils_test.go

36
weed/iam/sts/sts_service.go

@ -699,16 +699,18 @@ func (s *STSService) validateRoleAssumptionForWebIdentity(ctx context.Context, r
return fmt.Errorf("web identity token cannot be empty")
}
// Basic role ARN format validation
expectedPrefix := "arn:aws:iam::role/"
if len(roleArn) < len(expectedPrefix) || roleArn[:len(expectedPrefix)] != expectedPrefix {
return fmt.Errorf("invalid role ARN format: got %s, expected format: %s*", roleArn, expectedPrefix)
// Validate role ARN and extract role information
// Accepts both arn:aws:iam::role/X and arn:aws:iam::ACCOUNT:role/X
arnInfo := utils.ParseRoleARN(roleArn)
if arnInfo.RoleName == "" {
return fmt.Errorf("invalid role ARN format: %s, expected format: arn:aws:iam::[ACCOUNT_ID:]role/ROLE_NAME", roleArn)
}
// Extract role name and validate ARN format
roleName := utils.ExtractRoleNameFromArn(roleArn)
if roleName == "" {
return fmt.Errorf("invalid role ARN format: %s", roleArn)
// Log ARN details for debugging
if arnInfo.AccountID != "" {
glog.V(4).Infof("Role ARN validation: role=%s, account=%s (standard format)", arnInfo.RoleName, arnInfo.AccountID)
} else {
glog.V(4).Infof("Role ARN validation: role=%s (legacy format)", arnInfo.RoleName)
}
// CRITICAL SECURITY: Perform trust policy validation
@ -736,16 +738,18 @@ func (s *STSService) validateRoleAssumptionForCredentials(ctx context.Context, r
return fmt.Errorf("identity cannot be nil")
}
// Basic role ARN format validation
expectedPrefix := "arn:aws:iam::role/"
if len(roleArn) < len(expectedPrefix) || roleArn[:len(expectedPrefix)] != expectedPrefix {
return fmt.Errorf("invalid role ARN format: got %s, expected format: %s*", roleArn, expectedPrefix)
// Validate role ARN and extract role information
// Accepts both arn:aws:iam::role/X and arn:aws:iam::ACCOUNT:role/X
arnInfo := utils.ParseRoleARN(roleArn)
if arnInfo.RoleName == "" {
return fmt.Errorf("invalid role ARN format: %s, expected format: arn:aws:iam::[ACCOUNT_ID:]role/ROLE_NAME", roleArn)
}
// Extract role name and validate ARN format
roleName := utils.ExtractRoleNameFromArn(roleArn)
if roleName == "" {
return fmt.Errorf("invalid role ARN format: %s", roleArn)
// Log ARN details for debugging
if arnInfo.AccountID != "" {
glog.V(4).Infof("Role ARN validation: role=%s, account=%s (standard format)", arnInfo.RoleName, arnInfo.AccountID)
} else {
glog.V(4).Infof("Role ARN validation: role=%s (legacy format)", arnInfo.RoleName)
}
// CRITICAL SECURITY: Perform trust policy validation

270
weed/iam/utils/arn_utils.go

@ -1,39 +1,263 @@
// Package utils provides utility functions for AWS IAM ARN parsing and role extraction.
package utils
import "strings"
// ExtractRoleNameFromPrincipal extracts role name from principal ARN
// Handles both STS assumed role and IAM role formats
// ARN parsing constants for AWS IAM and STS services
const (
// stsPrefix is the common prefix for all AWS STS ARNs
stsPrefix = "arn:aws:sts::"
// stsAssumedRoleMarker is the marker that identifies assumed role ARNs
stsAssumedRoleMarker = "assumed-role/"
// iamPrefix is the common prefix for all AWS IAM ARNs
iamPrefix = "arn:aws:iam::"
// iamRoleMarker is the marker that identifies IAM role ARNs
iamRoleMarker = "role/"
)
// ARNInfo contains structured information about a parsed AWS ARN.
// This provides more context than a simple string extraction, making it
// easier to debug issues and support different ARN formats.
type ARNInfo struct {
// Original is the original ARN string that was parsed
Original string
// RoleName is the extracted role name (without "role/" prefix)
// May include path components (e.g., "Division/Team/RoleName")
// Empty string indicates an invalid ARN
RoleName string
// AccountID is the AWS account ID if present in the ARN
// Empty string for legacy format ARNs (arn:aws:iam::role/Name)
// Non-empty for standard format ARNs (arn:aws:iam::ACCOUNT:role/Name)
AccountID string
}
// ExtractRoleNameFromPrincipal extracts the role name from an AWS principal ARN.
//
// It handles both STS assumed role and IAM role ARN formats, supporting both
// legacy (without account ID) and standard AWS (with account ID) formats:
// - arn:aws:sts::assumed-role/RoleName/SessionName (legacy STS format)
// - arn:aws:sts::ACCOUNT:assumed-role/RoleName/SessionName (standard STS format)
// - arn:aws:iam::role/RoleName (legacy IAM format)
// - arn:aws:iam::ACCOUNT:role/RoleName (standard IAM format)
//
// For STS assumed-role ARNs, it extracts the role name from the "assumed-role/"
// component, which is the second part of the path (e.g., "Role" from
// "arn:aws:sts::ACCOUNT:assumed-role/Role/Session"). For IAM role ARNs, it
// delegates to ExtractRoleNameFromArn.
//
// Returns an empty string if the principal format is invalid or unrecognized.
//
// Parameters:
// - principal: The AWS principal ARN string to extract the role name from
//
// Returns:
// - The extracted role name (without "role/" prefix)
// - Empty string if the principal is invalid or no role name is found
func ExtractRoleNameFromPrincipal(principal string) string {
// Handle STS assumed role format: arn:aws:sts::assumed-role/RoleName/SessionName
stsPrefix := "arn:aws:sts::assumed-role/"
// Handle STS assumed role format
if strings.HasPrefix(principal, stsPrefix) {
remainder := principal[len(stsPrefix):]
// Split on first '/' to get role name
if slashIndex := strings.Index(remainder, "/"); slashIndex != -1 {
return remainder[:slashIndex]
// Validate ARN structure: should be either "assumed-role/..." or "ACCOUNT:assumed-role/..."
// Split on ':' to separate account ID (if present) from resource type
resourcePart := remainder
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
// Standard format with account ID: "ACCOUNT:assumed-role/..."
resourcePart = remainder[colonIdx+1:]
}
// If no slash found, return the remainder (edge case)
return remainder
}
// Handle IAM role format: arn:aws:iam::role/RoleName
iamPrefix := "arn:aws:iam::role/"
if strings.HasPrefix(principal, iamPrefix) {
return principal[len(iamPrefix):]
// Verify the resource type is exactly "assumed-role/"
if !strings.HasPrefix(resourcePart, stsAssumedRoleMarker) {
return ""
}
// Extract role name after "assumed-role/"
afterMarker := resourcePart[len(stsAssumedRoleMarker):]
if slash := strings.Index(afterMarker, "/"); slash != -1 {
return afterMarker[:slash]
}
return afterMarker
}
// Return empty string to signal invalid ARN format
// This allows callers to handle the error explicitly instead of masking it
return ""
// Handle IAM role format
return ExtractRoleNameFromArn(principal)
}
// ExtractRoleNameFromArn extracts role name from an IAM role ARN
// Specifically handles: arn:aws:iam::role/RoleName
// ExtractRoleNameFromArn extracts the role name from an AWS IAM role ARN.
//
// It handles both legacy and standard AWS IAM role ARN formats:
// - arn:aws:iam::role/RoleName (legacy format without account ID)
// - arn:aws:iam::ACCOUNT:role/RoleName (standard AWS format with account ID)
//
// The function validates the ARN structure to ensure the resource type is exactly
// "role", preventing security issues where malicious ARNs like
// "arn:aws:iam::123456789012:user/role/malicious" could be accepted.
//
// If the ARN contains a path component (e.g., "role/Division/Team/RoleName"),
// the entire path is returned after "role/".
//
// Returns an empty string if:
// - The ARN does not start with "arn:aws:iam::"
// - The resource type is not exactly "role"
// - The input is empty or invalid
//
// This function is commonly used in STS (Security Token Service) role assumption
// validation to extract the role name from principal ARNs or to validate IAM
// role ARNs during credential assumption checks.
//
// Parameters:
// - roleArn: The IAM role ARN string to extract the role name from
//
// Returns:
// - The extracted role name (without "role/" prefix, may include path)
// - Empty string if the ARN is invalid or no role name is found
func ExtractRoleNameFromArn(roleArn string) string {
prefix := "arn:aws:iam::role/"
if strings.HasPrefix(roleArn, prefix) && len(roleArn) > len(prefix) {
return roleArn[len(prefix):]
if !strings.HasPrefix(roleArn, iamPrefix) {
return ""
}
return ""
remainder := roleArn[len(iamPrefix):]
// Validate ARN structure: should be either "role/..." or "ACCOUNT:role/..."
// Split on ':' to separate account ID (if present) from resource type
resourcePart := remainder
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
// Standard format with account ID: "ACCOUNT:role/..."
resourcePart = remainder[colonIdx+1:]
}
// Verify the resource type is exactly "role/"
if !strings.HasPrefix(resourcePart, iamRoleMarker) {
return ""
}
// Extract role name after "role/"
return resourcePart[len(iamRoleMarker):]
}
// ParseRoleARN parses an AWS IAM role ARN and returns structured information.
//
// It handles both legacy and standard AWS IAM role ARN formats:
// - arn:aws:iam::role/RoleName (legacy format without account ID)
// - arn:aws:iam::ACCOUNT:role/RoleName (standard AWS format with account ID)
//
// The function validates the ARN structure to ensure the resource type is exactly
// "role", extracting the role name and account ID (if present).
//
// Parameters:
// - roleArn: The IAM role ARN string to parse
//
// Returns:
// - ARNInfo struct containing parsed information
// - RoleName will be empty if parsing fails or ARN is malformed
func ParseRoleARN(roleArn string) ARNInfo {
info := ARNInfo{
Original: roleArn,
}
if !strings.HasPrefix(roleArn, iamPrefix) {
return info
}
remainder := roleArn[len(iamPrefix):]
// Validate ARN structure: should be either "role/..." or "ACCOUNT:role/..."
// Split on ':' to separate account ID (if present) from resource type
resourcePart := remainder
accountPart := ""
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
// Standard format with account ID: "ACCOUNT:role/..."
accountPart = remainder[:colonIdx]
resourcePart = remainder[colonIdx+1:]
}
// Verify the resource type is exactly "role/"
if !strings.HasPrefix(resourcePart, iamRoleMarker) {
// Invalid resource type
return info
}
// Extract role name (everything after "role/")
info.RoleName = resourcePart[len(iamRoleMarker):]
if info.RoleName == "" {
// Empty role name is invalid
return info
}
// Set account ID if present
info.AccountID = accountPart
return info
}
// ParsePrincipalARN parses an AWS principal ARN (STS or IAM) and returns structured information.
//
// It handles both STS assumed role and IAM role ARN formats:
// - arn:aws:sts::assumed-role/RoleName/SessionName (legacy STS format)
// - arn:aws:sts::ACCOUNT:assumed-role/RoleName/SessionName (standard STS format)
// - arn:aws:iam::role/RoleName (legacy IAM format)
// - arn:aws:iam::ACCOUNT:role/RoleName (standard IAM format)
//
// The function validates the ARN structure to ensure the resource type is exactly
// "assumed-role" for STS or delegates to ParseRoleARN for IAM ARNs.
//
// Parameters:
// - principal: The AWS principal ARN string to parse
//
// Returns:
// - ARNInfo struct containing parsed information
// - RoleName will be empty if parsing fails or ARN is malformed
func ParsePrincipalARN(principal string) ARNInfo {
// Handle STS assumed role format
if strings.HasPrefix(principal, stsPrefix) {
info := ARNInfo{
Original: principal,
}
remainder := principal[len(stsPrefix):]
// Validate ARN structure: should be either "assumed-role/..." or "ACCOUNT:assumed-role/..."
// Split on ':' to separate account ID (if present) from resource type
resourcePart := remainder
accountPart := ""
if colonIdx := strings.Index(remainder, ":"); colonIdx != -1 {
// Standard format with account ID: "ACCOUNT:assumed-role/..."
accountPart = remainder[:colonIdx]
resourcePart = remainder[colonIdx+1:]
}
// Verify the resource type is exactly "assumed-role/"
if !strings.HasPrefix(resourcePart, stsAssumedRoleMarker) {
// Invalid resource type
return info
}
// Extract role name (between "assumed-role/" and next "/")
afterMarker := resourcePart[len(stsAssumedRoleMarker):]
if slash := strings.Index(afterMarker, "/"); slash != -1 {
info.RoleName = afterMarker[:slash]
} else {
info.RoleName = afterMarker
}
// Validate that role name is not empty
if info.RoleName == "" {
return info
}
// Set account ID if present
info.AccountID = accountPart
return info
}
// Handle IAM role format
return ParseRoleARN(principal)
}

646
weed/iam/utils/arn_utils_test.go

@ -0,0 +1,646 @@
package utils
import "testing"
// TestExtractRoleNameFromArn tests the ExtractRoleNameFromArn function with
// comprehensive test cases covering:
// - Legacy IAM role ARN format (arn:aws:iam::role/RoleName)
// - Standard AWS IAM role ARN format (arn:aws:iam::ACCOUNT:role/RoleName)
// - Role names with path components (e.g., role/Path/To/RoleName)
// - Invalid and edge case ARNs (missing prefix, wrong service, empty strings)
//
// The test uses table-driven test pattern with multiple scenarios for each
// format to ensure robust handling of both legacy and modern AWS ARN formats.
func TestExtractRoleNameFromArn(t *testing.T) {
testCases := []struct {
name string
roleArn string
expected string
}{
// Legacy format (without account ID)
{
name: "legacy_format_simple_role_name",
roleArn: "arn:aws:iam::role/default",
expected: "default",
},
{
name: "legacy_format_custom_role_name",
roleArn: "arn:aws:iam::role/MyRole",
expected: "MyRole",
},
{
name: "legacy_format_with_path",
roleArn: "arn:aws:iam::role/Path/MyRole",
expected: "Path/MyRole",
},
{
name: "legacy_format_with_nested_path",
roleArn: "arn:aws:iam::role/Division/Team/Role",
expected: "Division/Team/Role",
},
// Standard AWS format (with account ID)
{
name: "standard_format_simple_role_name",
roleArn: "arn:aws:iam::123456789012:role/default",
expected: "default",
},
{
name: "standard_format_custom_role_name",
roleArn: "arn:aws:iam::999999999999:role/MyRole",
expected: "MyRole",
},
{
name: "standard_format_with_path",
roleArn: "arn:aws:iam::123456789012:role/Path/MyRole",
expected: "Path/MyRole",
},
{
name: "standard_format_with_nested_path",
roleArn: "arn:aws:iam::123456789012:role/Division/Team/Role",
expected: "Division/Team/Role",
},
// Edge cases and invalid formats
{
name: "invalid_arn_missing_prefix",
roleArn: "invalid-arn",
expected: "",
},
{
name: "invalid_arn_incomplete",
roleArn: "arn:aws:iam::",
expected: "",
},
{
name: "invalid_arn_no_role_marker",
roleArn: "arn:aws:iam::123456789012:user/username",
expected: "",
},
{
name: "invalid_arn_wrong_service",
roleArn: "arn:aws:sts::assumed-role/Role/Session",
expected: "",
},
{
name: "empty_string",
roleArn: "",
expected: "",
},
{
name: "role_marker_no_name",
roleArn: "arn:aws:iam::role/",
expected: "",
},
{
name: "standard_format_role_marker_no_name",
roleArn: "arn:aws:iam::123456789012:role/",
expected: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ExtractRoleNameFromArn(tc.roleArn)
if result != tc.expected {
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.roleArn, result, tc.expected)
}
})
}
}
// TestExtractRoleNameFromPrincipal tests the ExtractRoleNameFromPrincipal function
// with comprehensive test cases covering:
// - STS assumed role ARN format (arn:aws:sts::assumed-role/RoleName/SessionName)
// - Standard AWS STS format (arn:aws:sts::ACCOUNT:assumed-role/RoleName/SessionName)
// - IAM role ARN format delegated to ExtractRoleNameFromArn
// - Both legacy and standard IAM role formats with and without paths
// - Invalid and edge case principals (wrong format, empty strings)
//
// The test ensures that ExtractRoleNameFromPrincipal correctly handles both
// STS temporary credentials and permanent IAM role ARNs used in different
// authentication and authorization workflows.
func TestExtractRoleNameFromPrincipal(t *testing.T) {
testCases := []struct {
name string
principal string
expected string
}{
// STS assumed role format (legacy)
{
name: "sts_assumed_role_legacy",
principal: "arn:aws:sts::assumed-role/RoleName/SessionName",
expected: "RoleName",
},
{
name: "sts_assumed_role_legacy_no_session",
principal: "arn:aws:sts::assumed-role/RoleName",
expected: "RoleName",
},
// STS assumed role format (standard with account ID)
{
name: "sts_assumed_role_standard",
principal: "arn:aws:sts::123456789012:assumed-role/RoleName/SessionName",
expected: "RoleName",
},
{
name: "sts_assumed_role_standard_no_session",
principal: "arn:aws:sts::123456789012:assumed-role/RoleName",
expected: "RoleName",
},
// IAM role format (legacy)
{
name: "iam_role_legacy",
principal: "arn:aws:iam::role/RoleName",
expected: "RoleName",
},
{
name: "iam_role_legacy_with_path",
principal: "arn:aws:iam::role/Path/RoleName",
expected: "Path/RoleName",
},
// IAM role format (standard)
{
name: "iam_role_standard",
principal: "arn:aws:iam::123456789012:role/RoleName",
expected: "RoleName",
},
{
name: "iam_role_standard_with_path",
principal: "arn:aws:iam::123456789012:role/Path/RoleName",
expected: "Path/RoleName",
},
// Invalid formats
{
name: "invalid_principal",
principal: "invalid-arn",
expected: "",
},
{
name: "empty_string",
principal: "",
expected: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ExtractRoleNameFromPrincipal(tc.principal)
if result != tc.expected {
t.Errorf("ExtractRoleNameFromPrincipal(%q) = %q, want %q", tc.principal, result, tc.expected)
}
})
}
}
// TestParseRoleARN tests the ParseRoleARN function with structured ARNInfo output
func TestParseRoleARN(t *testing.T) {
testCases := []struct {
name string
roleArn string
expected ARNInfo
}{
{
name: "legacy_format_simple_role",
roleArn: "arn:aws:iam::role/MyRole",
expected: ARNInfo{
Original: "arn:aws:iam::role/MyRole",
RoleName: "MyRole",
AccountID: "",
},
},
{
name: "legacy_format_with_path",
roleArn: "arn:aws:iam::role/Division/Team/MyRole",
expected: ARNInfo{
Original: "arn:aws:iam::role/Division/Team/MyRole",
RoleName: "Division/Team/MyRole",
AccountID: "",
},
},
{
name: "standard_format_simple_role",
roleArn: "arn:aws:iam::123456789012:role/MyRole",
expected: ARNInfo{
Original: "arn:aws:iam::123456789012:role/MyRole",
RoleName: "MyRole",
AccountID: "123456789012",
},
},
{
name: "standard_format_with_path",
roleArn: "arn:aws:iam::999999999999:role/Path/To/MyRole",
expected: ARNInfo{
Original: "arn:aws:iam::999999999999:role/Path/To/MyRole",
RoleName: "Path/To/MyRole",
AccountID: "999999999999",
},
},
{
name: "invalid_arn_missing_prefix",
roleArn: "invalid-arn",
expected: ARNInfo{
Original: "invalid-arn",
RoleName: "",
AccountID: "",
},
},
{
name: "invalid_arn_no_role_marker",
roleArn: "arn:aws:iam::123456789012:user/username",
expected: ARNInfo{
Original: "arn:aws:iam::123456789012:user/username",
RoleName: "",
AccountID: "",
},
},
{
name: "invalid_arn_empty_role_name",
roleArn: "arn:aws:iam::role/",
expected: ARNInfo{
Original: "arn:aws:iam::role/",
RoleName: "",
AccountID: "",
},
},
{
name: "invalid_arn_empty_role_name_standard_format",
roleArn: "arn:aws:iam::123456789012:role/",
expected: ARNInfo{
Original: "arn:aws:iam::123456789012:role/",
RoleName: "",
AccountID: "",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ParseRoleARN(tc.roleArn)
if result.Original != tc.expected.Original {
t.Errorf("ParseRoleARN(%q).Original = %q, want %q", tc.roleArn, result.Original, tc.expected.Original)
}
if result.RoleName != tc.expected.RoleName {
t.Errorf("ParseRoleARN(%q).RoleName = %q, want %q", tc.roleArn, result.RoleName, tc.expected.RoleName)
}
if result.AccountID != tc.expected.AccountID {
t.Errorf("ParseRoleARN(%q).AccountID = %q, want %q", tc.roleArn, result.AccountID, tc.expected.AccountID)
}
})
}
}
// TestParsePrincipalARN tests the ParsePrincipalARN function with structured ARNInfo output
func TestParsePrincipalARN(t *testing.T) {
testCases := []struct {
name string
principal string
expected ARNInfo
}{
{
name: "sts_assumed_role_legacy",
principal: "arn:aws:sts::assumed-role/MyRole/SessionName",
expected: ARNInfo{
Original: "arn:aws:sts::assumed-role/MyRole/SessionName",
RoleName: "MyRole",
AccountID: "",
},
},
{
name: "sts_assumed_role_standard",
principal: "arn:aws:sts::123456789012:assumed-role/MyRole/SessionName",
expected: ARNInfo{
Original: "arn:aws:sts::123456789012:assumed-role/MyRole/SessionName",
RoleName: "MyRole",
AccountID: "123456789012",
},
},
{
name: "sts_assumed_role_no_session",
principal: "arn:aws:sts::assumed-role/MyRole",
expected: ARNInfo{
Original: "arn:aws:sts::assumed-role/MyRole",
RoleName: "MyRole",
AccountID: "",
},
},
{
name: "iam_role_legacy",
principal: "arn:aws:iam::role/MyRole",
expected: ARNInfo{
Original: "arn:aws:iam::role/MyRole",
RoleName: "MyRole",
AccountID: "",
},
},
{
name: "iam_role_standard",
principal: "arn:aws:iam::123456789012:role/MyRole",
expected: ARNInfo{
Original: "arn:aws:iam::123456789012:role/MyRole",
RoleName: "MyRole",
AccountID: "123456789012",
},
},
{
name: "iam_role_with_path",
principal: "arn:aws:iam::999999999999:role/Division/Team/MyRole",
expected: ARNInfo{
Original: "arn:aws:iam::999999999999:role/Division/Team/MyRole",
RoleName: "Division/Team/MyRole",
AccountID: "999999999999",
},
},
{
name: "invalid_principal",
principal: "invalid-arn",
expected: ARNInfo{
Original: "invalid-arn",
RoleName: "",
AccountID: "",
},
},
{
name: "invalid_sts_empty_role_name",
principal: "arn:aws:sts::assumed-role/",
expected: ARNInfo{
Original: "arn:aws:sts::assumed-role/",
RoleName: "",
AccountID: "",
},
},
{
name: "invalid_sts_empty_role_name_standard_format",
principal: "arn:aws:sts::123456789012:assumed-role/",
expected: ARNInfo{
Original: "arn:aws:sts::123456789012:assumed-role/",
RoleName: "",
AccountID: "",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ParsePrincipalARN(tc.principal)
if result.Original != tc.expected.Original {
t.Errorf("ParsePrincipalARN(%q).Original = %q, want %q", tc.principal, result.Original, tc.expected.Original)
}
if result.RoleName != tc.expected.RoleName {
t.Errorf("ParsePrincipalARN(%q).RoleName = %q, want %q", tc.principal, result.RoleName, tc.expected.RoleName)
}
if result.AccountID != tc.expected.AccountID {
t.Errorf("ParsePrincipalARN(%q).AccountID = %q, want %q", tc.principal, result.AccountID, tc.expected.AccountID)
}
})
}
}
// TestSecurityMaliciousUserARNs tests that user ARNs with "role/" in the path are correctly rejected
// to prevent security vulnerabilities where malicious ARNs could bypass validation.
func TestSecurityMaliciousUserARNs(t *testing.T) {
maliciousARNs := []struct {
arn string
description string
}{
{"arn:aws:iam::123456789012:user/role/malicious", "user ARN with role/ in path"},
{"arn:aws:iam::123456789012:policy/role/some-policy", "policy ARN with role/ in name"},
{"arn:aws:iam::123456789012:group/role/some-group", "group ARN with role/ in name"},
{"arn:aws:iam::user/role/test", "legacy user ARN with role/ in path"},
}
for _, tc := range maliciousARNs {
t.Run(tc.description, func(t *testing.T) {
roleName := ExtractRoleNameFromArn(tc.arn)
if roleName != "" {
t.Errorf("Security issue: %s was accepted and returned role name '%s'", tc.description, roleName)
}
arnInfo := ParseRoleARN(tc.arn)
if arnInfo.RoleName != "" {
t.Errorf("Security issue: %s was accepted by ParseRoleARN and returned role name '%s'", tc.description, arnInfo.RoleName)
}
})
}
}
// TestSecurityMaliciousSTSUserARNs tests that STS user ARNs with "assumed-role/" are correctly rejected
// to prevent security vulnerabilities.
func TestSecurityMaliciousSTSUserARNs(t *testing.T) {
maliciousARNs := []struct {
arn string
description string
}{
{"arn:aws:sts::123456789012:user/assumed-role/malicious", "STS user with assumed-role in path"},
{"arn:aws:sts::user/assumed-role/test", "legacy STS user with assumed-role in path"},
}
for _, tc := range maliciousARNs {
t.Run(tc.description, func(t *testing.T) {
roleName := ExtractRoleNameFromPrincipal(tc.arn)
if roleName != "" {
t.Errorf("Security issue: %s was accepted and returned role name '%s'", tc.description, roleName)
}
arnInfo := ParsePrincipalARN(tc.arn)
if arnInfo.RoleName != "" {
t.Errorf("Security issue: %s was accepted by ParsePrincipalARN and returned role name '%s'", tc.description, arnInfo.RoleName)
}
})
}
}
// TestEdgeCaseMultipleRoleMarkers tests ARNs with multiple "role/" markers in the path.
// AWS role names can legitimately contain slashes for path components, so "role/role/name"
// should be accepted as a valid role name "role/name".
func TestEdgeCaseMultipleRoleMarkers(t *testing.T) {
testCases := []struct {
name string
arn string
expected string
useSTS bool
}{
{
name: "legacy_format_role_in_path",
arn: "arn:aws:iam::role/role/name",
expected: "role/name",
useSTS: false,
},
{
name: "standard_format_role_in_path",
arn: "arn:aws:iam::123456789012:role/role/name",
expected: "role/name",
useSTS: false,
},
{
name: "multiple_role_markers_in_path",
arn: "arn:aws:iam::123456789012:role/role/role/role",
expected: "role/role/role",
useSTS: false,
},
{
name: "sts_assumed_role_with_role_in_path",
arn: "arn:aws:sts::123456789012:assumed-role/role/SessionId",
expected: "role",
useSTS: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if tc.useSTS {
result := ExtractRoleNameFromPrincipal(tc.arn)
if result != tc.expected {
t.Errorf("ExtractRoleNameFromPrincipal(%q) = %q, want %q", tc.arn, result, tc.expected)
}
} else {
result := ExtractRoleNameFromArn(tc.arn)
if result != tc.expected {
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.arn, result, tc.expected)
}
}
})
}
}
// TestEdgeCaseConsecutiveSlashes tests ARNs with consecutive slashes which are
// preserved as valid path components. These are technically allowed in role names,
// though they're rare in practice.
func TestEdgeCaseConsecutiveSlashes(t *testing.T) {
testCases := []struct {
name string
roleArn string
expected string
}{
{
name: "consecutive_slashes_immediately_after_role",
roleArn: "arn:aws:iam::role//name",
expected: "/name",
},
{
name: "consecutive_slashes_in_path",
roleArn: "arn:aws:iam::123456789012:role/Division//Team/Role",
expected: "Division//Team/Role",
},
{
name: "multiple_consecutive_slashes",
roleArn: "arn:aws:iam::123456789012:role/////Name",
expected: "////Name",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ExtractRoleNameFromArn(tc.roleArn)
if result != tc.expected {
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.roleArn, result, tc.expected)
}
})
}
}
// TestEdgeCaseSpecialCharactersInRoleName tests valid AWS role name special characters.
// AWS IAM role names support: letters, numbers, and special characters +=,.@-_
func TestEdgeCaseSpecialCharactersInRoleName(t *testing.T) {
testCases := []struct {
name string
roleArn string
expected string
}{
{
name: "role_name_with_hyphens",
roleArn: "arn:aws:iam::123456789012:role/My-Role-Name",
expected: "My-Role-Name",
},
{
name: "role_name_with_underscores",
roleArn: "arn:aws:iam::123456789012:role/My_Role_Name",
expected: "My_Role_Name",
},
{
name: "role_name_with_dots",
roleArn: "arn:aws:iam::123456789012:role/my.role.name",
expected: "my.role.name",
},
{
name: "role_name_with_at_sign",
roleArn: "arn:aws:iam::123456789012:role/Role@Domain",
expected: "Role@Domain",
},
{
name: "role_name_with_plus_and_equals",
roleArn: "arn:aws:iam::123456789012:role/Role+=Name",
expected: "Role+=Name",
},
{
name: "role_name_with_commas",
roleArn: "arn:aws:iam::123456789012:role/Role,Name",
expected: "Role,Name",
},
{
name: "role_name_with_mixed_special_chars",
roleArn: "arn:aws:iam::123456789012:role/App-Env.Region+Shard@Version",
expected: "App-Env.Region+Shard@Version",
},
{
name: "path_with_special_characters",
roleArn: "arn:aws:iam::123456789012:role/Org-1/Team.Dev+Staging@us-east-1/App",
expected: "Org-1/Team.Dev+Staging@us-east-1/App",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ExtractRoleNameFromArn(tc.roleArn)
if result != tc.expected {
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.roleArn, result, tc.expected)
}
// Also test ParseRoleARN to ensure structured parsing works
arnInfo := ParseRoleARN(tc.roleArn)
if arnInfo.RoleName != tc.expected {
t.Errorf("ParseRoleARN(%q).RoleName = %q, want %q", tc.roleArn, arnInfo.RoleName, tc.expected)
}
})
}
}
// TestEdgeCaseExtremelyLongRoleName tests role names near AWS limits.
// AWS IAM role names can be up to 64 characters, and paths can be up to 512 characters total.
func TestEdgeCaseExtremelyLongRoleName(t *testing.T) {
// Create a role name at the 64 character limit for a single role name segment
longRoleName := "a-role-name-that-is-nearly-at-the-sixty-four-character-limit-yes"
if len(longRoleName) > 64 {
t.Skipf("Test role name is too long: %d characters", len(longRoleName))
}
testCases := []struct {
name string
roleArn string
expected string
}{
{
name: "role_name_at_max_length",
roleArn: "arn:aws:iam::123456789012:role/" + longRoleName,
expected: longRoleName,
},
{
name: "role_with_long_path_components",
roleArn: "arn:aws:iam::123456789012:role/organization/department/team/application/environment/role",
expected: "organization/department/team/application/environment/role",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := ExtractRoleNameFromArn(tc.roleArn)
if result != tc.expected {
t.Errorf("ExtractRoleNameFromArn(%q) = %q, want %q", tc.roleArn, result, tc.expected)
}
// Also test ParseRoleARN
arnInfo := ParseRoleARN(tc.roleArn)
if arnInfo.RoleName != tc.expected {
t.Errorf("ParseRoleARN(%q).RoleName = %q, want %q", tc.roleArn, arnInfo.RoleName, tc.expected)
}
})
}
}
Loading…
Cancel
Save