diff --git a/libs/go/sia/aws/lambda/lambda.go b/libs/go/sia/aws/lambda/lambda.go index f98b96031b3..c7236ee0798 100644 --- a/libs/go/sia/aws/lambda/lambda.go +++ b/libs/go/sia/aws/lambda/lambda.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "log" + "os" "strings" "github.com/AthenZ/athenz/libs/go/sia/aws/attestation" @@ -38,8 +39,49 @@ import ( "github.com/aws/aws-sdk-go-v2/service/ssm" ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" "github.com/aws/aws-sdk-go-v2/service/sts" + ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" ) +// AthenzIdentityRequest carries all the attributes required to fetch an Athenz +// service identity from within an AWS Lambda function. In addition to the fields +// supported by GetAthenzIdentity, it allows the caller to control how the +// attestation data is generated: either with AWS temporary credentials obtained +// from an STS assume-role call (the default), or with an AWS issued OIDC web +// identity token when UseWebIdentityToken is set to true. +type AthenzIdentityRequest struct { + AthenzDomain string // name of the domain for the identity + AthenzService string // name of the service for the identity + AthenzProvider string // name of the athenz provider + ZTSUrl string // the ZTS server url to contact + SanDNSDomains []string // dns domains for the SAN dnsName entries in the csr + SpiffeTrustDomain string // spiffe trust domain for the SAN uri entry in the csr + CsrSubjectFields util.CsrSubjectFields // subject fields for the csr + InstanceIdSanDNS bool // include the instance id in the SAN dnsName entries + AwsAccount string // aws account id; if empty it is extracted from the caller identity + Region string // aws region name; if empty the AWS_REGION env variable is used + UseRegionalSTS bool // use the regional sts endpoint instead of the global one + OmitDomain bool // the attestation iam role only includes the service name + RolePath string // iam role path prefix (temporary credentials only) + UseWebIdentityToken bool // use a web identity token instead of temporary credentials + WebIdentityAudience string // audience for the web identity token; if empty the zts url is used + WebIdentitySigningAlgorithm string // signing algorithm for the web identity token (RS256 or ES384); default ES384 + WebIdentityDurationSeconds int32 // lifetime of the web identity token in seconds (60-3600); default 300 + WebIdentityTags []ststypes.Tag // optional key/value pairs added as custom claims to the web identity token +} + +const ( + defaultWebIdentitySigningAlgorithm = "ES384" + defaultWebIdentityDurationSeconds = int32(300) +) + +// stsAttestationData generates the attestation data based on AWS temporary credentials. +// It is a package-level variable so tests can replace it with a stub. +var stsAttestationData = attestation.New + +// awsAccountIdFetcher returns the aws account id for the current caller. +// It is a package-level variable so tests can replace it with a stub. +var awsAccountIdFetcher = meta.GetAccountId + // ACMClientInterface defines the interface for ACM client operations type ACMClientInterface interface { ListCertificates(ctx context.Context, params *acm.ListCertificatesInput, optFns ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) @@ -73,6 +115,7 @@ func getLambdaAttestationData(domain, service, account string) ([]byte, error) { return json.Marshal(data) } +// Deprecated: Use GetAthenzServiceIdentity function to get identity certificates func GetAthenzIdentity(athenzDomain, athenzService, athenzProvider, ztsUrl string, sanDNSDomains []string, spiffeTrustDomain string, csrSubjectFields util.CsrSubjectFields) (*util.SiaCertData, error) { awsAccount := meta.GetAccountId() athenzDomain = strings.ToLower(athenzDomain) @@ -82,7 +125,79 @@ func GetAthenzIdentity(athenzDomain, athenzService, athenzProvider, ztsUrl strin return getInternalAthenzIdentity(athenzDomain, athenzService, athenzProvider, ztsUrl, awsAccount, sanDNSDomains, spiffeTrustDomain, csrSubjectFields, false) } -// Deprecated: Use GetAthenzIdentity functions to get identity certificates +// GetAthenzServiceIdentity requests an Athenz X.509 service identity certificate from +// ZTS for the given lambda function. The attestation data presented to ZTS is generated +// either from AWS temporary credentials or, when request.UseWebIdentityToken is enabled, +// from an AWS issued OIDC web identity token (JWT). +func GetAthenzServiceIdentity(request *AthenzIdentityRequest) (*util.SiaCertData, error) { + + if request == nil { + return nil, fmt.Errorf("no athenz identity request specified") + } + athenzDomain := strings.ToLower(request.AthenzDomain) + athenzService := strings.ToLower(request.AthenzService) + athenzProvider := strings.ToLower(request.AthenzProvider) + if athenzDomain == "" || athenzService == "" || athenzProvider == "" || request.ZTSUrl == "" { + return nil, fmt.Errorf("athenz domain, service, provider and zts url must be specified") + } + + // if the account is not given, we're going to extract it from our caller identity + awsAccount := request.AwsAccount + if awsAccount == "" { + awsAccount = awsAccountIdFetcher() + if awsAccount == "" { + return nil, fmt.Errorf("unable to determine aws account id") + } + } + + // if the region is not given, we're going to use the one configured + // in our lambda runtime environment + region := request.Region + if region == "" { + region = os.Getenv("AWS_REGION") + } + + privateKey, err := util.GenerateKeyPair(2048) + if err != nil { + return nil, err + } + attestationData, err := getAthenzAttestationData(request, athenzDomain, athenzService, awsAccount, region) + if err != nil { + return nil, err + } + + instanceId := getLambdaInstance(awsAccount, athenzService) + return util.RegisterIdentity(athenzDomain, athenzService, athenzProvider, request.ZTSUrl, instanceId, + attestationData, request.SpiffeTrustDomain, request.SanDNSDomains, request.CsrSubjectFields, + request.InstanceIdSanDNS, privateKey) +} + +func getAthenzAttestationData(request *AthenzIdentityRequest, athenzDomain, athenzService, awsAccount, region string) (string, error) { + + if !request.UseWebIdentityToken { + // lambda functions have no ec2 instance identity document/signature, so the + // attestation data only carries the assumed role temporary credentials + return stsAttestationData(athenzDomain, athenzService, region, awsAccount, "", "", + request.UseRegionalSTS, request.OmitDomain, request.RolePath) + } + + audience := request.WebIdentityAudience + if audience == "" { + audience = request.ZTSUrl + } + signingAlgorithm := request.WebIdentitySigningAlgorithm + if signingAlgorithm == "" { + signingAlgorithm = defaultWebIdentitySigningAlgorithm + } + durationSeconds := request.WebIdentityDurationSeconds + if durationSeconds == 0 { + durationSeconds = defaultWebIdentityDurationSeconds + } + return attestation.NewWebIdentity(athenzDomain, athenzService, region, audience, signingAlgorithm, + request.UseRegionalSTS, request.OmitDomain, durationSeconds, request.WebIdentityTags, "", "") +} + +// Deprecated: Use GetAthenzServiceIdentity function to get identity certificates func GetAWSLambdaServiceCertificate(ztsUrl, athenzProvider, athenzDomain, service, awsAccount string, sanDNSDomains []string, instanceIdSanDNS bool) (tls.Certificate, error) { athenzDomain = strings.ToLower(athenzDomain) diff --git a/libs/go/sia/aws/lambda/lambda_test.go b/libs/go/sia/aws/lambda/lambda_test.go index 51a17873ecc..773b3265137 100644 --- a/libs/go/sia/aws/lambda/lambda_test.go +++ b/libs/go/sia/aws/lambda/lambda_test.go @@ -18,13 +18,28 @@ package lambda import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" "errors" + "io" + "math/big" + "net/http" + "net/http/httptest" "testing" + "time" + "github.com/AthenZ/athenz/clients/go/zts" + "github.com/AthenZ/athenz/libs/go/sia/aws/attestation" + "github.com/AthenZ/athenz/libs/go/sia/aws/stssession" "github.com/AthenZ/athenz/libs/go/sia/util" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/acm" acmtypes "github.com/aws/aws-sdk-go-v2/service/acm/types" + ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -532,3 +547,575 @@ func TestSetCertificateTags(t *testing.T) { }) } } + +// ztsTestServer is a mock ZTS instance register endpoint. It signs the submitted CSR +// with a test CA so that the caller receives a certificate matching the private key +// that was generated during the identity registration. +type ztsTestServer struct { + *httptest.Server + caCert *x509.Certificate + caKey *rsa.PrivateKey + caCertPem string + statusCode int // response status code; 201 when not set + requests []zts.InstanceRegisterInformation // captured register requests +} + +func newZtsTestServer(t *testing.T) *ztsTestServer { + t.Helper() + + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Athenz Test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + caDer, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + require.NoError(t, err) + caCert, err := x509.ParseCertificate(caDer) + require.NoError(t, err) + + server := &ztsTestServer{ + caCert: caCert, + caKey: caKey, + caCertPem: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDer})), + } + router := http.NewServeMux() + router.HandleFunc("POST /instance", server.registerInstance(t)) + server.Server = httptest.NewServer(router) + t.Cleanup(server.Close) + return server +} + +func (s *ztsTestServer) registerInstance(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + contents, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("unable to read register request: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + var info zts.InstanceRegisterInformation + if err := json.Unmarshal(contents, &info); err != nil { + t.Errorf("unable to parse register request: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + s.requests = append(s.requests, info) + + if s.statusCode != 0 && s.statusCode != http.StatusCreated { + w.WriteHeader(s.statusCode) + _, _ = w.Write([]byte(`{"code":403,"message":"unable to verify attestation data"}`)) + return + } + + certPem, err := s.signCsr(info.Csr) + if err != nil { + t.Errorf("unable to sign csr: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + identity := &zts.InstanceIdentity{ + Provider: zts.ServiceName(info.Provider), + Name: zts.ServiceName(string(info.Domain) + "." + string(info.Service)), + InstanceId: "id-001", + X509Certificate: certPem, + X509CertificateSigner: s.caCertPem, + } + data, _ := json.Marshal(identity) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(data) + } +} + +func (s *ztsTestServer) signCsr(csrPem string) (string, error) { + block, _ := pem.Decode([]byte(csrPem)) + if block == nil { + return "", errors.New("unable to decode csr pem block") + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + return "", err + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(100), + Subject: csr.Subject, + DNSNames: csr.DNSNames, + URIs: csr.URIs, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + } + certDer, err := x509.CreateCertificate(rand.Reader, template, s.caCert, csr.PublicKey, s.caKey) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDer})), nil +} + +// lastRequest returns the register request received by the server along with its +// parsed attestation data and CSR. +func (s *ztsTestServer) lastRequest(t *testing.T) (zts.InstanceRegisterInformation, attestation.AttestationData, *x509.CertificateRequest) { + t.Helper() + require.NotEmpty(t, s.requests, "no register request received by the zts server") + info := s.requests[len(s.requests)-1] + + var data attestation.AttestationData + require.NoError(t, json.Unmarshal([]byte(info.AttestationData), &data)) + + block, _ := pem.Decode([]byte(info.Csr)) + require.NotNil(t, block) + csr, err := x509.ParseCertificateRequest(block.Bytes) + require.NoError(t, err) + + return info, data, csr +} + +// stsAttestationCall captures the arguments passed to the stubbed temporary +// credentials attestation data generator +type stsAttestationCall struct { + domain string + service string + region string + account string + ec2Document string + ec2Signature string + useRegionalSTS bool + omitDomain bool + rolePath string + called bool +} + +// stubStsAttestationData replaces the temporary credentials attestation generator with +// one that records its arguments and returns the given attestation data/error +func stubStsAttestationData(t *testing.T, attestationData string, err error) *stsAttestationCall { + t.Helper() + orig := stsAttestationData + t.Cleanup(func() { stsAttestationData = orig }) + + call := &stsAttestationCall{} + stsAttestationData = func(domain, service, region, account, ec2Document, ec2Signature string, useRegionalSTS, omitDomain bool, rolePath string) (string, error) { + call.domain = domain + call.service = service + call.region = region + call.account = account + call.ec2Document = ec2Document + call.ec2Signature = ec2Signature + call.useRegionalSTS = useRegionalSTS + call.omitDomain = omitDomain + call.rolePath = rolePath + call.called = true + return attestationData, err + } + return call +} + +// webIdentityCall captures the arguments passed to the stubbed web identity token fetcher +type webIdentityCall struct { + useRegionalSTS bool + region string + audience string + signingAlgorithm string + durationSeconds int32 + tags []ststypes.Tag + called bool +} + +// stubWebIdentityTokenFetcher replaces the sts web identity token fetcher with one +// that records its arguments and returns the given token/error +func stubWebIdentityTokenFetcher(t *testing.T, token string, err error) *webIdentityCall { + t.Helper() + orig := stssession.WebIdentityTokenFetcher + t.Cleanup(func() { stssession.WebIdentityTokenFetcher = orig }) + + call := &webIdentityCall{} + stssession.WebIdentityTokenFetcher = func(useRegionalSTS bool, region, audience, signingAlgorithm string, durationSeconds int32, tags []ststypes.Tag) (string, error) { + call.useRegionalSTS = useRegionalSTS + call.region = region + call.audience = audience + call.signingAlgorithm = signingAlgorithm + call.durationSeconds = durationSeconds + call.tags = tags + call.called = true + return token, err + } + return call +} + +// stubAwsAccountIdFetcher replaces the aws account id lookup with one returning the given value +func stubAwsAccountIdFetcher(t *testing.T, account string) { + t.Helper() + orig := awsAccountIdFetcher + t.Cleanup(func() { awsAccountIdFetcher = orig }) + awsAccountIdFetcher = func() string { + return account + } +} + +func TestGetAthenzServiceIdentityInvalidRequest(t *testing.T) { + tests := []struct { + name string + request *AthenzIdentityRequest + expectedError string + }{ + { + name: "nil request", + request: nil, + expectedError: "no athenz identity request specified", + }, + { + name: "empty domain", + request: &AthenzIdentityRequest{ + AthenzService: "api", + AthenzProvider: "athenz.aws.us-west-2", + ZTSUrl: "http://localhost:4443", + }, + expectedError: "athenz domain, service, provider and zts url must be specified", + }, + { + name: "empty service", + request: &AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzProvider: "athenz.aws.us-west-2", + ZTSUrl: "http://localhost:4443", + }, + expectedError: "athenz domain, service, provider and zts url must be specified", + }, + { + name: "empty provider", + request: &AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + ZTSUrl: "http://localhost:4443", + }, + expectedError: "athenz domain, service, provider and zts url must be specified", + }, + { + name: "empty zts url", + request: &AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + AthenzProvider: "athenz.aws.us-west-2", + }, + expectedError: "athenz domain, service, provider and zts url must be specified", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + siaCertData, err := GetAthenzServiceIdentity(tt.request) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + assert.Nil(t, siaCertData) + }) + } +} + +func TestGetAthenzServiceIdentityUnknownAccount(t *testing.T) { + stubAwsAccountIdFetcher(t, "") + call := stubStsAttestationData(t, "", nil) + + siaCertData, err := GetAthenzServiceIdentity(&AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + AthenzProvider: "athenz.aws.us-west-2", + ZTSUrl: "http://localhost:4443", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to determine aws account id") + assert.Nil(t, siaCertData) + assert.False(t, call.called) +} + +func TestGetAthenzServiceIdentityTempCredentials(t *testing.T) { + ztsServer := newZtsTestServer(t) + call := stubStsAttestationData(t, `{"role":"sports.api","access":"access-key","secret":"secret-key","token":"session-token"}`, nil) + + siaCertData, err := GetAthenzServiceIdentity(&AthenzIdentityRequest{ + AthenzDomain: "Sports", + AthenzService: "API", + AthenzProvider: "Athenz.AWS.us-west-2", + ZTSUrl: ztsServer.URL, + AwsAccount: "123456789012", + Region: "us-west-2", + UseRegionalSTS: true, + OmitDomain: true, + RolePath: "athenz", + SanDNSDomains: []string{"athenz.io"}, + CsrSubjectFields: util.CsrSubjectFields{ + Country: "US", + Organization: "Athenz", + }, + }) + require.NoError(t, err) + require.NotNil(t, siaCertData) + assert.NotEmpty(t, siaCertData.X509CertificatePem) + assert.NotEmpty(t, siaCertData.PrivateKeyPem) + assert.Equal(t, ztsServer.caCertPem, siaCertData.X509CertificateSignerPem) + assert.NotNil(t, siaCertData.TLSCertificate.Certificate) + + // the domain, service and provider names must have been lowercased and all + // the aws specific attributes passed to the attestation data generator + + require.True(t, call.called) + assert.Equal(t, "sports", call.domain) + assert.Equal(t, "api", call.service) + assert.Equal(t, "us-west-2", call.region) + assert.Equal(t, "123456789012", call.account) + assert.True(t, call.useRegionalSTS) + assert.True(t, call.omitDomain) + assert.Equal(t, "athenz", call.rolePath) + // lambda functions have no ec2 identity document + assert.Empty(t, call.ec2Document) + assert.Empty(t, call.ec2Signature) + + info, data, csr := ztsServer.lastRequest(t) + assert.Equal(t, "sports", string(info.Domain)) + assert.Equal(t, "api", string(info.Service)) + assert.Equal(t, "athenz.aws.us-west-2", string(info.Provider)) + assert.Equal(t, "access-key", data.Access) + assert.Empty(t, data.IdentityToken) + assert.Equal(t, "sports.api", csr.Subject.CommonName) + assert.Equal(t, []string{"api.sports.athenz.io"}, csr.DNSNames) + assert.Contains(t, csrUris(csr), "athenz://instanceid/athenz.aws.us-west-2/lambda-123456789012-api") +} + +func TestGetAthenzServiceIdentityTempCredentialsError(t *testing.T) { + ztsServer := newZtsTestServer(t) + stubStsAttestationData(t, "", errors.New("unable to assume role")) + + siaCertData, err := GetAthenzServiceIdentity(&AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + AthenzProvider: "athenz.aws.us-west-2", + ZTSUrl: ztsServer.URL, + AwsAccount: "123456789012", + SanDNSDomains: []string{"athenz.io"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to assume role") + assert.Nil(t, siaCertData) + assert.Empty(t, ztsServer.requests) +} + +func TestGetAthenzServiceIdentityAccountFromCallerIdentity(t *testing.T) { + ztsServer := newZtsTestServer(t) + stubAwsAccountIdFetcher(t, "098765432109") + call := stubStsAttestationData(t, `{"role":"sports.api"}`, nil) + + siaCertData, err := GetAthenzServiceIdentity(&AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + AthenzProvider: "athenz.aws.us-west-2", + ZTSUrl: ztsServer.URL, + SanDNSDomains: []string{"athenz.io"}, + }) + require.NoError(t, err) + require.NotNil(t, siaCertData) + assert.Equal(t, "098765432109", call.account) + + _, _, csr := ztsServer.lastRequest(t) + assert.Contains(t, csrUris(csr), "athenz://instanceid/athenz.aws.us-west-2/lambda-098765432109-api") +} + +func TestGetAthenzServiceIdentityRegionFromEnv(t *testing.T) { + ztsServer := newZtsTestServer(t) + t.Setenv("AWS_REGION", "us-east-1") + call := stubStsAttestationData(t, `{"role":"sports.api"}`, nil) + + _, err := GetAthenzServiceIdentity(&AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + AthenzProvider: "athenz.aws.us-east-1", + ZTSUrl: ztsServer.URL, + AwsAccount: "123456789012", + SanDNSDomains: []string{"athenz.io"}, + }) + require.NoError(t, err) + assert.Equal(t, "us-east-1", call.region) +} + +func TestGetAthenzServiceIdentityInstanceIdSanDNS(t *testing.T) { + ztsServer := newZtsTestServer(t) + stubStsAttestationData(t, `{"role":"sports.api"}`, nil) + + _, err := GetAthenzServiceIdentity(&AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + AthenzProvider: "athenz.aws.us-west-2", + ZTSUrl: ztsServer.URL, + AwsAccount: "123456789012", + SanDNSDomains: []string{"athenz.io"}, + InstanceIdSanDNS: true, + }) + require.NoError(t, err) + + _, _, csr := ztsServer.lastRequest(t) + assert.Equal(t, []string{"api.sports.athenz.io", "lambda-123456789012-api.instanceid.athenz.athenz.io"}, csr.DNSNames) +} + +func TestGetAthenzServiceIdentityWebIdentityDefaults(t *testing.T) { + ztsServer := newZtsTestServer(t) + call := stubWebIdentityTokenFetcher(t, "header.payload.signature", nil) + stsCall := stubStsAttestationData(t, "", errors.New("temp credentials must not be used")) + + siaCertData, err := GetAthenzServiceIdentity(&AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + AthenzProvider: "athenz.aws.us-west-2", + ZTSUrl: ztsServer.URL, + AwsAccount: "123456789012", + Region: "us-west-2", + SanDNSDomains: []string{"athenz.io"}, + UseWebIdentityToken: true, + }) + require.NoError(t, err) + require.NotNil(t, siaCertData) + assert.False(t, stsCall.called) + + // audience defaults to the zts url, algorithm to ES384 and duration to 300 seconds + + require.True(t, call.called) + assert.Equal(t, ztsServer.URL, call.audience) + assert.Equal(t, "ES384", call.signingAlgorithm) + assert.Equal(t, int32(300), call.durationSeconds) + assert.Equal(t, "us-west-2", call.region) + assert.False(t, call.useRegionalSTS) + assert.Nil(t, call.tags) + + _, data, _ := ztsServer.lastRequest(t) + assert.Equal(t, "header.payload.signature", data.IdentityToken) + assert.Equal(t, "sports.api", data.Role) + assert.Equal(t, "sports.api", data.CommonName) + // the temporary credentials must not be included in the web identity token path + assert.Empty(t, data.Access) + assert.Empty(t, data.Secret) + assert.Empty(t, data.Token) +} + +func TestGetAthenzServiceIdentityWebIdentityCustomValues(t *testing.T) { + ztsServer := newZtsTestServer(t) + call := stubWebIdentityTokenFetcher(t, "header.payload.signature", nil) + tags := []ststypes.Tag{ + {Key: aws.String("athenz-domain"), Value: aws.String("sports")}, + } + + siaCertData, err := GetAthenzServiceIdentity(&AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + AthenzProvider: "athenz.aws.us-west-2", + ZTSUrl: ztsServer.URL, + AwsAccount: "123456789012", + Region: "us-west-2", + SanDNSDomains: []string{"athenz.io"}, + UseRegionalSTS: true, + OmitDomain: true, + UseWebIdentityToken: true, + WebIdentityAudience: "https://zts.athenz.io", + WebIdentitySigningAlgorithm: "RS256", + WebIdentityDurationSeconds: 600, + WebIdentityTags: tags, + }) + require.NoError(t, err) + require.NotNil(t, siaCertData) + + require.True(t, call.called) + assert.Equal(t, "https://zts.athenz.io", call.audience) + assert.Equal(t, "RS256", call.signingAlgorithm) + assert.Equal(t, int32(600), call.durationSeconds) + assert.True(t, call.useRegionalSTS) + assert.Equal(t, tags, call.tags) + + // with omit domain enabled the attestation role only carries the service name + + _, data, _ := ztsServer.lastRequest(t) + assert.Equal(t, "api", data.Role) + assert.Equal(t, "sports.api", data.CommonName) + assert.Equal(t, "header.payload.signature", data.IdentityToken) +} + +func TestGetAthenzServiceIdentityWebIdentityErrors(t *testing.T) { + tests := []struct { + name string + signingAlgorithm string + durationSeconds int32 + fetcherError error + expectedError string + }{ + { + name: "invalid signing algorithm", + signingAlgorithm: "HS256", + expectedError: "invalid signing algorithm", + }, + { + name: "duration below minimum", + durationSeconds: 30, + expectedError: "invalid durationSeconds", + }, + { + name: "duration above maximum", + durationSeconds: 7200, + expectedError: "invalid durationSeconds", + }, + { + name: "token fetch failure", + fetcherError: errors.New("sts failure"), + expectedError: "sts failure", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ztsServer := newZtsTestServer(t) + stubWebIdentityTokenFetcher(t, "", tt.fetcherError) + + siaCertData, err := GetAthenzServiceIdentity(&AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + AthenzProvider: "athenz.aws.us-west-2", + ZTSUrl: ztsServer.URL, + AwsAccount: "123456789012", + Region: "us-west-2", + SanDNSDomains: []string{"athenz.io"}, + UseWebIdentityToken: true, + WebIdentitySigningAlgorithm: tt.signingAlgorithm, + WebIdentityDurationSeconds: tt.durationSeconds, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + assert.Nil(t, siaCertData) + assert.Empty(t, ztsServer.requests) + }) + } +} + +func TestGetAthenzServiceIdentityZtsFailure(t *testing.T) { + ztsServer := newZtsTestServer(t) + ztsServer.statusCode = http.StatusForbidden + stubStsAttestationData(t, `{"role":"sports.api"}`, nil) + + siaCertData, err := GetAthenzServiceIdentity(&AthenzIdentityRequest{ + AthenzDomain: "sports", + AthenzService: "api", + AthenzProvider: "athenz.aws.us-west-2", + ZTSUrl: ztsServer.URL, + AwsAccount: "123456789012", + SanDNSDomains: []string{"athenz.io"}, + }) + require.Error(t, err) + assert.Nil(t, siaCertData) + assert.Len(t, ztsServer.requests, 1) +} + +func csrUris(csr *x509.CertificateRequest) []string { + uris := make([]string, 0, len(csr.URIs)) + for _, uri := range csr.URIs { + uris = append(uris, uri.String()) + } + return uris +}