agent/taskresource/credentialspec/credentialspec_linux.go (493 lines of code) (raw):
//go:build linux
// +build linux
// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License"). You may
// not use this file except in compliance with the License. A copy of the
// License is located at
//
// http://aws.amazon.com/apache2.0/
//
// or in the "license" file accompanying this file. This file is distributed
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
// express or implied. See the License for the specific language governing
// permissions and limitations under the License.
package credentialspec
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/aws/amazon-ecs-agent/agent/asm"
asmfactory "github.com/aws/amazon-ecs-agent/agent/asm/factory"
"github.com/aws/amazon-ecs-agent/agent/s3"
"github.com/aws/amazon-ecs-agent/agent/ssm"
"github.com/aws/amazon-ecs-agent/agent/utils"
"github.com/aws/aws-sdk-go/aws/arn"
"github.com/cihub/seelog"
s3factory "github.com/aws/amazon-ecs-agent/agent/s3/factory"
ssmfactory "github.com/aws/amazon-ecs-agent/agent/ssm/factory"
"github.com/aws/amazon-ecs-agent/ecs-agent/credentials"
credentialsfetcherclient "github.com/aws/amazon-ecs-agent/ecs-agent/gmsacredclient"
"github.com/pkg/errors"
)
const (
// envSkipCredentialsFetcherInvocation is an environment setting that can be used to skip
// credentials fetcher daemon invocation. This is useful for integration and
// functional-tests but should not be set for any non-test use-case.
envSkipCredentialsFetcherInvocation = "ZZZ_SKIP_CREDENTIALS_FETCHER_INVOCATION_CHECK_NOT_SUPPORTED_IN_PRODUCTION"
)
// CredentialSpecResource is the abstraction for credentialspec resources
type CredentialSpecResource struct {
*CredentialSpecResourceCommon
// This stores the identifier associated with the kerberos tickets created for the task
leaseID string
// identify domainless or domain-joined gMSA
isDomainlessGmsa bool
// This stores credspec arn and the corresponding service account name, domain name
// * key := credentialspec:ssmARN, value := corresponding ServiceAccountInfo
// * key := credentialspec:asmARN, value := corresponding ServiceAccountInfo
ServiceAccountInfoMap map[string]ServiceAccountInfo
// This stores credspec contents associated to all the containers of the task
credentialsFetcherRequest []string
}
// ServiceAccountInfo contains account info associated to a credentialspec
type ServiceAccountInfo struct {
serviceAccountName string
domainName string
domainlessGmsaUserArn string
credentialSpecContent string
}
// DomainlessUserCredentials represents user credentials for accessing the gMSA principals
type DomainlessUserCredentials struct {
Username string `json:"username"`
Password string `json:"password"`
DomainName string `json:"domainName"`
}
// CredentialSpec object schema
type CredentialSpecSchema struct {
CmsPlugins []string `json:"CmsPlugins"`
DomainJoinConfig struct {
Sid string `json:"Sid"`
MachineAccountName string `json:"MachineAccountName"`
GUID string `json:"Guid"`
DNSTreeName string `json:"DnsTreeName"`
DNSName string `json:"DnsName"`
NetBiosName string `json:"NetBiosName"`
} `json:"DomainJoinConfig"`
ActiveDirectoryConfig struct {
GroupManagedServiceAccounts []struct {
Name string `json:"Name"`
Scope string `json:"Scope"`
} `json:"GroupManagedServiceAccounts"`
} `json:"ActiveDirectoryConfig"`
}
// CredentialSpec domainless object schema
type CredentialSpecDomainlessSchema struct {
CmsPlugins []string `json:"CmsPlugins"`
DomainJoinConfig struct {
Sid string `json:"Sid"`
MachineAccountName string `json:"MachineAccountName"`
GUID string `json:"Guid"`
DNSTreeName string `json:"DnsTreeName"`
DNSName string `json:"DnsName"`
NetBiosName string `json:"NetBiosName"`
} `json:"DomainJoinConfig"`
ActiveDirectoryConfig struct {
GroupManagedServiceAccounts []struct {
Name string `json:"Name"`
Scope string `json:"Scope"`
} `json:"GroupManagedServiceAccounts"`
HostAccountConfig struct {
PortableCcgVersion string `json:"PortableCcgVersion"`
PluginGUID string `json:"PluginGUID"`
PluginInput struct {
CredentialArn string `json:"CredentialArn"`
} `json:"PluginInput"`
} `json:"HostAccountConfig"`
} `json:"ActiveDirectoryConfig"`
}
// NewCredentialSpecResource creates a new CredentialSpecResource object
func NewCredentialSpecResource(taskARN, region string,
executionCredentialsID string,
credentialsManager credentials.Manager,
ssmClientCreator ssmfactory.SSMClientCreator,
s3ClientCreator s3factory.S3ClientCreator,
asmClientCreator asmfactory.ClientCreator,
credentialSpecContainerMap map[string]string) (*CredentialSpecResource, error) {
s := &CredentialSpecResource{
CredentialSpecResourceCommon: &CredentialSpecResourceCommon{
taskARN: taskARN,
region: region,
credentialsManager: credentialsManager,
executionCredentialsID: executionCredentialsID,
ssmClientCreator: ssmClientCreator,
s3ClientCreator: s3ClientCreator,
secretsmanagerClientCreator: asmClientCreator,
CredSpecMap: make(map[string]string),
credentialSpecContainerMap: credentialSpecContainerMap,
},
ServiceAccountInfoMap: make(map[string]ServiceAccountInfo),
}
s.initStatusToTransition()
return s, nil
}
// Create is used to retrieve credentialspec resources for a given task
func (cs *CredentialSpecResource) Create() error {
var iamCredentials credentials.IAMRoleCredentials
executionCredentials, ok := cs.credentialsManager.GetTaskCredentials(cs.getExecutionCredentialsID())
if ok {
iamCredentials = executionCredentials.GetIAMRoleCredentials()
}
err := cs.retrieveCredentialSpecs(iamCredentials)
if err != nil {
return err
}
seelog.Infof("credentials fetcher daemon request: %v", cs.credentialsFetcherRequest)
// Check if skip credential fetcher invocation check override is present
skipSkipCredentialsFetcherInvocationCheck := utils.ParseBool(os.Getenv(envSkipCredentialsFetcherInvocation), false)
if skipSkipCredentialsFetcherInvocationCheck {
seelog.Info("Skipping credential fetcher invocation based on environment override")
testKrbFilePath := "/tmp/tgt"
os.Create(testKrbFilePath)
// assign temporary variable for test
cs.leaseID = "12345"
for k := range cs.ServiceAccountInfoMap {
cs.CredSpecMap[k] = testKrbFilePath
}
return nil
}
if cs.isDomainlessGmsa {
err := cs.handleDomainlessKerberosTicketCreation()
if err != nil {
cs.setTerminalReason(err.Error())
return err
}
} else {
err := cs.handleKerberosTicketCreation()
if err != nil {
cs.setTerminalReason(err.Error())
return err
}
}
return nil
}
func (cs *CredentialSpecResource) retrieveCredentialSpecs(iamCredentials credentials.IAMRoleCredentials) error {
cs.isDomainlessGmsa = false
var wg sync.WaitGroup
errorEvents := make(chan error, len(cs.credentialSpecContainerMap))
for credSpecStr := range cs.credentialSpecContainerMap {
cs.isDomainlessGmsa = strings.Contains(credSpecStr, "credentialspecdomainless")
var credSpecSplit []string
if cs.isDomainlessGmsa {
credSpecSplit = strings.SplitAfterN(credSpecStr, "credentialspecdomainless:", 2)
} else {
credSpecSplit = strings.SplitAfterN(credSpecStr, "credentialspec:", 2)
}
if len(credSpecSplit) != 2 {
seelog.Errorf("Invalid credentialspec: %s", credSpecStr)
continue
}
credSpecValue := credSpecSplit[1]
if strings.HasPrefix(credSpecValue, "file://") {
wg.Add(1)
go cs.handleCredentialspecFile(credSpecStr, credSpecValue, &wg, errorEvents)
continue
}
parsedARN, err := arn.Parse(credSpecValue)
if err != nil {
cs.setTerminalReason(err.Error())
return err
}
parsedARNService := parsedARN.Service
switch parsedARNService {
case "s3":
wg.Add(1)
go cs.handleS3CredentialspecFile(credSpecStr, credSpecValue, iamCredentials, &wg, errorEvents)
case "ssm":
wg.Add(1)
go cs.handleSSMCredentialspecFile(credSpecStr, credSpecValue, iamCredentials, &wg, errorEvents)
default:
err = errors.New("unsupported credentialspec ARN, only s3/ssm ARNs are valid")
cs.setTerminalReason(err.Error())
return err
}
}
wg.Wait()
close(errorEvents)
if len(errorEvents) > 0 {
var terminalReasons []string
for err := range errorEvents {
terminalReasons = append(terminalReasons, err.Error())
}
errorString := strings.Join(terminalReasons, ";")
cs.setTerminalReason(errorString)
return errors.New(errorString)
}
return nil
}
func (cs *CredentialSpecResource) handleDomainlessKerberosTicketCreation() error {
// Create kerberos tickets for the gMSA service accounts in domain-less mode on the host location /var/credentials-fetcher/krbdir
var iamCredentials credentials.IAMRoleCredentials
for k, v := range cs.ServiceAccountInfoMap {
if v.domainlessGmsaUserArn != "" {
// get domain-user credentials from secrets manager
executionCredentials, ok := cs.credentialsManager.GetTaskCredentials(cs.getExecutionCredentialsID())
if ok {
iamCredentials = executionCredentials.GetIAMRoleCredentials()
}
asmClient, err := cs.secretsmanagerClientCreator.NewASMClient(cs.region, iamCredentials)
if err != nil {
return fmt.Errorf("unable to create ASM client: %v", err)
}
asmSecretData, err := asm.GetSecretFromASM(v.domainlessGmsaUserArn, asmClient)
if err != nil {
return fmt.Errorf("failed to retrieve credentials for domainless gMSA user %s: %w", v.domainlessGmsaUserArn, err)
}
creds := DomainlessUserCredentials{}
if err := json.Unmarshal([]byte(asmSecretData), &creds); err != nil {
return fmt.Errorf("failed to parse asmSecretData for the gMSA AD user: %w", err)
}
//set up server connection to communicate with credentials fetcher daemon
conn, err := credentialsfetcherclient.GetGrpcClientConnection()
if err != nil {
seelog.Errorf("failed to connect with credentials fetcher daemon: %s", err)
return err
}
seelog.Infof("grpc connection: %v", conn)
response, err := credentialsfetcherclient.NewCredentialsFetcherClient(conn, time.Minute).AddNonDomainJoinedKerberosLease(context.Background(),
[]string{v.credentialSpecContent}, creds.Username, creds.Password, creds.DomainName)
if err != nil {
cs.setTerminalReason(err.Error())
return fmt.Errorf("failed to create kerberos tickets associated service account %s: %w", v.domainlessGmsaUserArn, err)
}
cs.leaseID = response.LeaseID
seelog.Infof("credentials fetcher response leaseID: %v", cs.leaseID)
cs.CredSpecMap[k] = response.KerberosTicketPaths[0]
}
}
return nil
}
func (cs *CredentialSpecResource) HandleDomainlessKerberosTicketRenewal(iamCredentials credentials.IAMRoleCredentials) error {
//update the region if it is not already set
err := cs.UpdateRegionFromTask()
if err != nil {
return err
}
err = cs.retrieveCredentialSpecs(iamCredentials)
if err != nil {
return err
}
visitedDomainlessUser := make(map[string]bool)
// Renew kerberos tickets for the gMSA service accounts in domain-less mode on the host location /var/credentials-fetcher/krbdir
for _, v := range cs.ServiceAccountInfoMap {
if v.domainlessGmsaUserArn != "" {
_, ok := visitedDomainlessUser[v.domainlessGmsaUserArn]
if !ok {
visitedDomainlessUser[v.domainlessGmsaUserArn] = true
// get domain-user credentials from secrets manager
asmClient, err := cs.secretsmanagerClientCreator.NewASMClient(cs.region, iamCredentials)
if err != nil {
return fmt.Errorf("unable to create ASM client: %v", err)
}
asmSecretData, err := asm.GetSecretFromASM(v.domainlessGmsaUserArn, asmClient)
if err != nil {
return fmt.Errorf("failed to retrieve credentials for domainless gMSA user %s: %w", v.domainlessGmsaUserArn, err)
}
creds := DomainlessUserCredentials{}
if err := json.Unmarshal([]byte(asmSecretData), &creds); err != nil {
return fmt.Errorf("failed to parse asmSecretData for the gMSA AD user: %w", err)
}
//set up server connection to communicate with credentials fetcher daemon
conn, err := credentialsfetcherclient.GetGrpcClientConnection()
if err != nil {
seelog.Errorf("failed to connect with credentials fetcher daemon: %s", err)
return err
}
seelog.Infof("grpc connection: %v", conn)
_, err = credentialsfetcherclient.NewCredentialsFetcherClient(conn, time.Minute).RenewNonDomainJoinedKerberosLease(context.Background(),
creds.Username, creds.Password, creds.DomainName)
if err != nil {
cs.setTerminalReason(err.Error())
return fmt.Errorf("failed to renew kerberos tickets associated service account %s: %w", v.domainlessGmsaUserArn, err)
}
seelog.Infof("renewal is successful: %v", cs.leaseID)
}
}
}
return nil
}
func (cs *CredentialSpecResource) handleKerberosTicketCreation() error {
// Create kerberos tickets for the gMSA service accounts on the host location /var/credentials-fetcher/krbdir
if len(cs.credentialsFetcherRequest) > 0 {
//set up server connection to communicate with credentials fetcher daemon
conn, err := credentialsfetcherclient.GetGrpcClientConnection()
seelog.Infof("grpc connection: %v", conn)
if err != nil {
seelog.Errorf("failed to connect with credentials fetcher daemon: %s", err)
return err
}
// make the grpc call to add kerberos lease api to create kerberos tickets for the gmsa account
response, err := credentialsfetcherclient.NewCredentialsFetcherClient(conn, time.Minute).AddKerberosLease(context.Background(), cs.credentialsFetcherRequest)
if err != nil {
seelog.Errorf("failed to create kerberos tickets associated service account, error: %s", err)
cs.setTerminalReason(err.Error())
return err
}
cs.leaseID = response.LeaseID
seelog.Infof("credentials fetcher response leaseID: %v", cs.leaseID)
//update the mapping of credspec ARN to the kerberos ticket location on the container instance
for _, kerberosTicketLocation := range response.KerberosTicketPaths {
for k, v := range cs.ServiceAccountInfoMap {
result := strings.Contains(strings.ToLower(kerberosTicketLocation), strings.ToLower(v.serviceAccountName))
if result {
cs.CredSpecMap[k] = kerberosTicketLocation
break
}
}
}
}
return nil
}
func (cs *CredentialSpecResource) handleCredentialspecFile(originalCredentialSpecFile, credentialSpec string, wg *sync.WaitGroup, errorEvents chan error) {
defer wg.Done()
if !strings.HasPrefix(credentialSpec, "file://") {
err := errors.New("invalid credentialspec file specification")
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
fileName := strings.SplitAfterN(credentialSpec, "file://", 2)
data, err := os.ReadFile(fileName[1])
if err != nil {
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
credSpecData := string(data)
err = cs.updateCredSpecMapping(originalCredentialSpecFile, credSpecData)
if err != nil {
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
}
func (cs *CredentialSpecResource) handleS3CredentialspecFile(originalCredentialSpecARN, credentialSpecS3ARN string, iamCredentials credentials.IAMRoleCredentials, wg *sync.WaitGroup, errorEvents chan error) {
defer wg.Done()
if iamCredentials == (credentials.IAMRoleCredentials{}) {
err := errors.New("credentialspec resource: unable to find execution role credentials")
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
_, err := arn.Parse(credentialSpecS3ARN)
if err != nil {
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
bucket, key, err := s3.ParseS3ARN(credentialSpecS3ARN)
if err != nil {
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
s3Client, err := cs.s3ClientCreator.NewS3Client(bucket, cs.region, iamCredentials)
if err != nil {
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
credSpecJsonStringUnformatted, err := s3.GetObject(bucket, key, s3Client)
if err != nil {
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
credSpecJsonStringBytes := &bytes.Buffer{}
json.Compact(credSpecJsonStringBytes, []byte(credSpecJsonStringUnformatted))
credSpecJsonString := credSpecJsonStringBytes.String()
err = cs.updateCredSpecMapping(originalCredentialSpecARN, credSpecJsonString)
if err != nil {
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
}
func (cs *CredentialSpecResource) handleSSMCredentialspecFile(originalCredentialSpecARN, credentialSpecSSMARN string, iamCredentials credentials.IAMRoleCredentials, wg *sync.WaitGroup, errorEvents chan error) {
defer wg.Done()
if iamCredentials == (credentials.IAMRoleCredentials{}) {
err := errors.New("credentialspec resource: unable to find execution role credentials")
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
parsedARN, err := arn.Parse(credentialSpecSSMARN)
if err != nil {
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
// An SSM ARN is in the form of arn:aws:ssm:us-west-2:123456789012:parameter/a/b. The parsed ARN value
// would be parameter/a/b. The following code gets the SSM parameter by passing "/a/b" value to the
// GetParametersFromSSM method to retrieve the value in the parameter.
ssmParam := strings.SplitAfterN(parsedARN.Resource, "parameter", 2)
if len(ssmParam) != 2 {
err := fmt.Errorf("the provided SSM parameter:%s is in an invalid format", parsedARN.Resource)
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
ssmParams := []string{ssmParam[1]}
ssmClient := cs.ssmClientCreator.NewSSMClient(cs.region, iamCredentials)
seelog.Errorf("ssm secret resource: retrieving resource for secrets %v in region [%s] in task: [%s]", cs.region, ssmParams)
ssmParamMap, err := ssm.GetSecretsFromSSM(ssmParams, ssmClient)
if err != nil {
errorEvents <- fmt.Errorf("fetching secret data from SSM Parameter Store in %s: %v", ssmParamMap, err)
return
}
ssmParamData := ssmParamMap[ssmParam[1]]
err = cs.updateCredSpecMapping(originalCredentialSpecARN, ssmParamData)
if err != nil {
cs.setTerminalReason(err.Error())
errorEvents <- err
return
}
}
// updateCredSpecMapping updates the mapping of credentialSpec input and the corresponding service account info(serviceAccountName, DomainNAme)
func (cs *CredentialSpecResource) updateCredSpecMapping(credSpecInput, credSpecContent string) error {
cs.lock.Lock()
defer cs.lock.Unlock()
var serviceAccountName string
var domainName string
var domainlessGmsaUserArn string
//parse json to extract the service account name and the domain name
if cs.isDomainlessGmsa {
var credentialSpecDomainlessSchema CredentialSpecDomainlessSchema
// Unmarshal or Decode the JSON to the interface for domainless gmsa.
err := json.Unmarshal([]byte(credSpecContent), &credentialSpecDomainlessSchema)
if err != nil {
return fmt.Errorf("error unmarshalling credentialspec domainless %s : %w", credSpecContent, err)
}
serviceAccountName = credentialSpecDomainlessSchema.DomainJoinConfig.MachineAccountName
domainName = credentialSpecDomainlessSchema.DomainJoinConfig.DNSName
pluginInput := credentialSpecDomainlessSchema.ActiveDirectoryConfig.HostAccountConfig.PluginInput
domainlessGmsaUserArn = pluginInput.CredentialArn
} else {
var credentialSpecSchema CredentialSpecSchema
// Unmarshal or Decode the JSON to the interface for domainjoined gmsa.
err := json.Unmarshal([]byte(credSpecContent), &credentialSpecSchema)
if err != nil {
return fmt.Errorf("Error unmarshalling credentialspec domain-joined %s : %w", credSpecContent, err)
}
serviceAccountName = credentialSpecSchema.DomainJoinConfig.MachineAccountName
domainName = credentialSpecSchema.DomainJoinConfig.DNSName
domainlessGmsaUserArn = ""
//build request array for credentials fetcher daemon
cs.credentialsFetcherRequest = append(cs.credentialsFetcherRequest, credSpecContent)
}
if len(serviceAccountName) > 0 && len(domainName) > 0 {
cs.ServiceAccountInfoMap[credSpecInput] = ServiceAccountInfo{
serviceAccountName: serviceAccountName,
domainName: domainName,
domainlessGmsaUserArn: domainlessGmsaUserArn,
credentialSpecContent: credSpecContent,
}
}
return nil
}
// update region if is not set
func (cs *CredentialSpecResource) UpdateRegionFromTask() error {
// Parse taskARN
parsedARN, err := arn.Parse(cs.taskARN)
if err != nil {
return err
}
cs.region = parsedARN.Region
return nil
}
// Cleanup removes the credentialSpec created for the task
func (cs *CredentialSpecResource) Cleanup() error {
cs.clearKerberosTickets()
return nil
}
// clearKerberosTickets cycles through the lease directory in the host machine
// and removes the associated kerberos tickets
func (cs *CredentialSpecResource) clearKerberosTickets() {
cs.lock.Lock()
defer cs.lock.Unlock()
if cs.leaseID != "" {
//set up server connection to communicate with credentials fetcher daemon
conn, err := credentialsfetcherclient.GetGrpcClientConnection()
if err != nil {
seelog.Errorf("failed to connect with credentials fetcher daemon: %s", err)
}
_, err = credentialsfetcherclient.NewCredentialsFetcherClient(conn, time.Minute).DeleteKerberosLease(context.Background(), cs.leaseID)
if err != nil {
seelog.Errorf("Unable to cleanup kerberos tickets associated with leaseid: %s, error: %s", cs.leaseID, err)
}
}
for key := range cs.CredSpecMap {
if len(key) > 0 {
delete(cs.CredSpecMap, key)
delete(cs.ServiceAccountInfoMap, key)
}
}
}
// CredentialSpecResourceJSON is the json representation of the credentialspec resource
type CredentialSpecResourceJSON struct {
*CredentialSpecResourceJSONCommon
LeaseID string `json:"leaseID"`
}
func (cs *CredentialSpecResource) MarshallPlatformSpecificFields(credentialSpecResourceJSON *CredentialSpecResourceJSON) {
credentialSpecResourceJSON.LeaseID = cs.leaseID
}
func (cs *CredentialSpecResource) UnmarshallPlatformSpecificFields(credentialSpecResourceJSON CredentialSpecResourceJSON) {
cs.leaseID = credentialSpecResourceJSON.LeaseID
}