diff --git a/weed/iam/sts/sts_service.go b/weed/iam/sts/sts_service.go index fc7285a37..70b186d64 100644 --- a/weed/iam/sts/sts_service.go +++ b/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 diff --git a/weed/iam/utils/arn_utils.go b/weed/iam/utils/arn_utils.go index 3f8cf0b8f..5b29b7766 100644 --- a/weed/iam/utils/arn_utils.go +++ b/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) } diff --git a/weed/iam/utils/arn_utils_test.go b/weed/iam/utils/arn_utils_test.go new file mode 100644 index 000000000..7d0a8ce2d --- /dev/null +++ b/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) + } + }) + } +}