chore: centralise arn splitting

This commit is contained in:
Julian Sommer
2026-09-30 12:30:25 +10:00
parent a3ea962d1e
commit 524a7a519f
8 changed files with 128 additions and 38 deletions
+3 -3
View File
@@ -23,11 +23,11 @@ func FormatACMCertificate(region, accountID, id string) string {
// The id keeps everything after the first "/". Truncating at the last one would
// let a grant on certificate/<a> authorize a request naming certificate/<a>/<b>.
func ParseACMCertificateID(certARN string) (string, bool) {
parts := strings.SplitN(certARN, ":", 6)
if len(parts) != 6 || parts[0] != "arn" || parts[2] != "acm" {
service, _, _, resource, ok := Split(certARN)
if !ok || service != "acm" {
return "", false
}
kind, id, found := strings.Cut(parts[5], "/")
kind, id, found := strings.Cut(resource, "/")
if !found || ACMResourceType(kind) != ACMCertificate || id == "" {
return "", false
}
+14
View File
@@ -0,0 +1,14 @@
package arn
import "strings"
// Split breaks an arn:aws ARN into its service, region, account and resource
// components. The resource keeps any further colons or slashes verbatim. ok is
// false for anything outside the aws partition, the only one this stack builds.
func Split(s string) (service, region, account, resource string, ok bool) {
parts := strings.SplitN(s, ":", 6)
if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" {
return "", "", "", "", false
}
return parts[2], parts[3], parts[4], parts[5], true
}
+81
View File
@@ -0,0 +1,81 @@
package arn_test
import (
"testing"
"github.com/mulgadc/spinifex/spinifex/arn"
"github.com/stretchr/testify/assert"
)
func TestSplit(t *testing.T) {
tests := []struct {
name string
value string
service string
region string
account string
resource string
wantOK bool
}{
{
name: "regional ARN",
value: "arn:aws:ecs:ap-southeast-2:123456789012:cluster/web",
service: "ecs",
region: "ap-southeast-2",
account: "123456789012",
resource: "cluster/web",
wantOK: true,
},
{
name: "global ARN has an empty region",
value: "arn:aws:iam::123456789012:root",
service: "iam",
account: "123456789012",
resource: "root",
wantOK: true,
},
{
name: "resource keeps its own colons",
value: "arn:aws:rds:ap-southeast-2:123456789012:snapshot:rds:orders-2026-09-30",
service: "rds",
region: "ap-southeast-2",
account: "123456789012",
resource: "snapshot:rds:orders-2026-09-30",
wantOK: true,
},
{
name: "empty resource",
value: "arn:aws:ecs:ap-southeast-2:123456789012:",
service: "ecs",
region: "ap-southeast-2",
account: "123456789012",
wantOK: true,
},
{
name: "another partition",
value: "arn:aws-cn:ecs:cn-north-1:123456789012:cluster/web",
},
{
name: "not an ARN",
value: "urn:aws:ecs:ap-southeast-2:123456789012:cluster/web",
},
{
name: "too few components",
value: "arn:aws:ecs:ap-southeast-2:cluster/web",
},
{
name: "empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service, region, account, resource, ok := arn.Split(tt.value)
assert.Equal(t, tt.wantOK, ok)
assert.Equal(t, tt.service, service)
assert.Equal(t, tt.region, region)
assert.Equal(t, tt.account, account)
assert.Equal(t, tt.resource, resource)
})
}
}
+4 -4
View File
@@ -70,18 +70,18 @@ type ParsedELBv2 struct {
// arn:aws:elasticloadbalancing ARN naming one of the four resource types: an
// ARN this stack never builds has no resource it can correctly stand for.
func ParseELBv2(value string) (ParsedELBv2, bool) {
parts := strings.SplitN(value, ":", 6)
if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "elasticloadbalancing" {
service, region, account, rest, ok := Split(value)
if !ok || service != "elasticloadbalancing" {
return ParsedELBv2{}, false
}
rawKind, resource, found := strings.Cut(parts[5], "/")
rawKind, resource, found := strings.Cut(rest, "/")
if !found || rawKind == "" {
return ParsedELBv2{}, false
}
kind := ELBv2ResourceType(rawKind)
switch kind {
case ELBv2LoadBalancer, ELBv2TargetGroup, ELBv2Listener, ELBv2ListenerRule:
return ParsedELBv2{Region: parts[3], AccountID: parts[4], Kind: kind, Resource: resource}, true
return ParsedELBv2{Region: region, AccountID: account, Kind: kind, Resource: resource}, true
default:
return ParsedELBv2{}, false
}
+4 -3
View File
@@ -8,6 +8,7 @@ import (
"slices"
"strings"
"github.com/mulgadc/spinifex/spinifex/arn"
"github.com/mulgadc/spinifex/spinifex/awsec2query"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/gateway/bodyscope"
@@ -214,11 +215,11 @@ func tagARN(region, accountID, resourceARN string) string {
// resource ARN (arn:aws:ecr:<region>:<account>:repository/<name>). tagARN and
// the tag handlers share this one parser for that shape.
func RepositoryNameFromResourceARN(resourceARN string) (string, error) {
parts := strings.SplitN(resourceARN, ":", 6)
if len(parts) != 6 || parts[0] != "arn" || parts[2] != "ecr" {
service, _, _, resource, ok := arn.Split(resourceARN)
if !ok || service != "ecr" {
return "", invalidResourceARNError()
}
kind, name, found := strings.Cut(parts[5], "/")
kind, name, found := strings.Cut(resource, "/")
if !found || kind != repositoryResourceType || name == "" {
return "", invalidResourceARNError()
}
+11 -10
View File
@@ -8,6 +8,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ecs"
"github.com/mulgadc/spinifex/spinifex/arn"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/nats-io/nats.go/jetstream"
)
@@ -37,22 +38,22 @@ func ResourceARNSegment(resourceARN string) (string, error) {
// (.../cluster/{name}, .../task-definition/{family}:{rev});
// service/task/container-instance ARNs embed the owning cluster.
func parseResourceARN(resourceARN string) (kind ecsResourceKind, cluster, id, segment string, err error) {
// SplitN(..., 6) keeps the resource segment intact even though a
// task-definition id ("family:1") itself contains a colon.
parts := strings.SplitN(resourceARN, ":", 6)
if len(parts) != 6 || parts[0] != "arn" || parts[2] != "ecs" {
// Split keeps the resource segment intact even though a task-definition
// id ("family:1") itself contains a colon.
service, _, _, segment, ok := arn.Split(resourceARN)
if !ok || service != "ecs" {
return 0, "", "", "", errors.New(awserrors.ErrorECSInvalidParameter)
}
rtype, rest, ok := strings.Cut(parts[5], "/")
rtype, rest, ok := strings.Cut(segment, "/")
if !ok || rtype == "" || rest == "" {
return 0, "", "", "", errors.New(awserrors.ErrorECSInvalidParameter)
}
switch rtype {
case "cluster":
return ecsResourceCluster, rest, rest, parts[5], nil
return ecsResourceCluster, rest, rest, segment, nil
case "task-definition":
return ecsResourceTaskDefinition, "", rest, parts[5], nil
return ecsResourceTaskDefinition, "", rest, segment, nil
case "service", "task", "container-instance":
clusterName, name, ok := strings.Cut(rest, "/")
if !ok || clusterName == "" || name == "" {
@@ -60,11 +61,11 @@ func parseResourceARN(resourceARN string) (kind ecsResourceKind, cluster, id, se
}
switch rtype {
case "service":
return ecsResourceService, clusterName, name, parts[5], nil
return ecsResourceService, clusterName, name, segment, nil
case "task":
return ecsResourceTask, clusterName, name, parts[5], nil
return ecsResourceTask, clusterName, name, segment, nil
default:
return ecsResourceContainerInstance, clusterName, name, parts[5], nil
return ecsResourceContainerInstance, clusterName, name, segment, nil
}
default:
return 0, "", "", "", errors.New(awserrors.ErrorECSInvalidParameter)
+6 -14
View File
@@ -2001,15 +2001,11 @@ func (s *EKSServiceImpl) accountBucket(ctx context.Context, accountID string) (j
// (arn:aws:eks:<region>:<acct>:cluster/<name>), reporting false for any other
// ARN shape (e.g. nodegroup) the tag store does not back.
func clusterNameFromARN(arn string) (string, bool) {
const prefix = "arn:aws:eks:"
if !strings.HasPrefix(arn, prefix) {
service, _, _, resource, ok := resourcearn.Split(arn)
if !ok || service != "eks" {
return "", false
}
parts := strings.SplitN(arn, ":", 6)
if len(parts) != 6 {
return "", false
}
resType, name, found := strings.Cut(parts[5], "/")
resType, name, found := strings.Cut(resource, "/")
if !found || resType != "cluster" || name == "" {
return "", false
}
@@ -2020,15 +2016,11 @@ func clusterNameFromARN(arn string) (string, bool) {
// (arn:aws:eks:<region>:<acct>:nodegroup/<cluster>/<ng>/<uuid>), reporting false
// for any other ARN shape.
func nodegroupRefFromARN(arn string) (string, string, bool) {
const prefix = "arn:aws:eks:"
if !strings.HasPrefix(arn, prefix) {
service, _, _, resource, ok := resourcearn.Split(arn)
if !ok || service != "eks" {
return "", "", false
}
parts := strings.SplitN(arn, ":", 6)
if len(parts) != 6 {
return "", "", false
}
resType, rest, found := strings.Cut(parts[5], "/")
resType, rest, found := strings.Cut(resource, "/")
if !found || resType != "nodegroup" {
return "", "", false
}
+5 -4
View File
@@ -20,6 +20,7 @@ import (
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/mulgadc/spinifex/spinifex/arn"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/kvstore"
@@ -438,7 +439,7 @@ func matchAWSPrincipalEntry(clause, callerARN string) bool {
return true
}
if utils.IsAccountID(clause) {
clause = fmt.Sprintf("arn:aws:iam::%s:root", clause)
clause = arn.FormatIAMRoot(clause)
}
if clause == callerARN {
return true
@@ -485,11 +486,11 @@ type principalARN struct {
}
func parsePrincipalARN(arnStr string) (principalARN, bool) {
parts := strings.SplitN(arnStr, ":", 6)
if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[3] != "" {
service, region, account, resource, ok := arn.Split(arnStr)
if !ok || region != "" {
return principalARN{}, false
}
return principalARN{service: parts[2], account: parts[4], resource: parts[5]}, true
return principalARN{service: service, account: account, resource: resource}, true
}
func lastPathSegment(s string) string {