Skip to content

Commit cf402c1

Browse files
Inject domainless gmsa cred spec into Windows Container
1 parent 62665fa commit cf402c1

File tree

2 files changed

+1195
-272
lines changed

2 files changed

+1195
-272
lines changed

agent/taskresource/credentialspec/credentialspec_windows.go

+267-8
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package credentialspec
1818

1919
import (
2020
"crypto/sha256"
21+
"encoding/json"
2122
"fmt"
2223
"os"
2324
"path/filepath"
@@ -30,11 +31,14 @@ import (
3031
s3factory "github.com/aws/amazon-ecs-agent/agent/s3/factory"
3132
"github.com/aws/amazon-ecs-agent/agent/ssm"
3233
ssmfactory "github.com/aws/amazon-ecs-agent/agent/ssm/factory"
34+
"github.com/aws/amazon-ecs-agent/agent/utils"
3335
"github.com/aws/amazon-ecs-agent/agent/utils/ioutilwrapper"
3436
"github.com/aws/amazon-ecs-agent/agent/utils/oswrapper"
3537
"github.com/aws/aws-sdk-go/aws/arn"
38+
3639
"github.com/cihub/seelog"
3740
"github.com/pkg/errors"
41+
"golang.org/x/sys/windows/registry"
3842
)
3943

4044
const (
@@ -47,14 +51,38 @@ const (
4751
// Environment variables to setup resource location
4852
envProgramData = "ProgramData"
4953
dockerCredentialSpecDataDir = "docker/credentialspecs"
54+
ecsCcgPluginRegistryKeyRoot = `System\CurrentControlSet\Services\AmazonECSCCGPlugin`
55+
regKeyPathFormat = `HKEY_LOCAL_MACHINE\` + ecsCcgPluginRegistryKeyRoot + `\%s`
56+
57+
credentialSpecParseErrorMsgTemplate = "Unable to parse %s from credential spec"
58+
untypedMarshallErrorMsgTemplate = "Unable to marshal untyped object %s to type %s"
59+
)
60+
61+
var (
62+
// For ease of unit testing
63+
osWriteFileImpl = os.WriteFile
64+
osReadFileImpl = os.ReadFile
65+
osRemoveImpl = os.Remove
66+
readCredentialSpecImpl = readCredentialSpec
67+
writeCredentialSpecImpl = writeCredentialSpec
68+
readWriteDomainlessCredentialSpecImpl = readWriteDomainlessCredentialSpec
69+
setTaskExecutionCredentialsRegKeysImpl = setTaskExecutionCredentialsRegKeys
70+
handleNonFileDomainlessGMSACredSpecImpl = handleNonFileDomainlessGMSACredSpec
71+
deleteTaskExecutionCredentialsRegKeysImpl = deleteTaskExecutionCredentialsRegKeys
5072
)
5173

74+
type pluginInput struct {
75+
CredentialArn string `json:"credentialArn,omitempty"`
76+
RegKeyPath string `json:"regKeyPath,omitempty"`
77+
}
78+
5279
// CredentialSpecResource is the abstraction for credentialspec resources
5380
type CredentialSpecResource struct {
5481
*CredentialSpecResourceCommon
5582
ioutil ioutilwrapper.IOUtil
5683
// credentialSpecResourceLocation is the location for all the tasks' credentialspec artifacts
5784
credentialSpecResourceLocation string
85+
isDomainlessGMSATask bool
5886
}
5987

6088
// NewCredentialSpecResource creates a new CredentialSpecResource object
@@ -77,7 +105,8 @@ func NewCredentialSpecResource(taskARN, region string,
77105
CredSpecMap: make(map[string]string),
78106
credentialSpecContainerMap: credentialSpecContainerMap,
79107
},
80-
ioutil: ioutilwrapper.NewIOUtil(),
108+
ioutil: ioutilwrapper.NewIOUtil(),
109+
isDomainlessGMSATask: false,
81110
}
82111

83112
err := s.setCredentialSpecResourceLocation()
@@ -100,11 +129,15 @@ func (cs *CredentialSpecResource) Create() error {
100129
}
101130

102131
for credSpecStr := range cs.credentialSpecContainerMap {
103-
credSpecSplit := strings.SplitAfterN(credSpecStr, "credentialspec:", 2)
132+
credSpecSplit := strings.SplitAfterN(credSpecStr, ":", 2)
104133
if len(credSpecSplit) != 2 {
105134
seelog.Errorf("Invalid credentialspec: %s", credSpecStr)
106135
continue
107136
}
137+
credSpecPrefix := credSpecSplit[0]
138+
if credSpecPrefix == "credentialspecdomainless:" {
139+
cs.isDomainlessGMSATask = true
140+
}
108141
credSpecValue := credSpecSplit[1]
109142

110143
if strings.HasPrefix(credSpecValue, "file://") {
@@ -145,22 +178,59 @@ func (cs *CredentialSpecResource) Create() error {
145178
}
146179
}
147180

181+
if cs.isDomainlessGMSATask {
182+
// The domainless gMSA Windows Plugin needs the execution role credentials to pull customer secrets
183+
err = setTaskExecutionCredentialsRegKeysImpl(iamCredentials, cs.CredentialSpecResourceCommon.taskARN)
184+
if err != nil {
185+
cs.setTerminalReason(err.Error())
186+
return err
187+
}
188+
}
189+
148190
return nil
149191
}
150192

151193
func (cs *CredentialSpecResource) handleCredentialspecFile(credentialspec string) error {
152-
credSpecSplit := strings.SplitAfterN(credentialspec, "credentialspec:", 2)
194+
credSpecSplit := strings.SplitAfterN(credentialspec, ":", 2)
153195
if len(credSpecSplit) != 2 {
154196
seelog.Errorf("Invalid credentialspec: %s", credentialspec)
155197
return errors.New("invalid credentialspec file specification")
156198
}
199+
credSpecPrefix := credSpecSplit[0]
157200
credSpecFile := credSpecSplit[1]
158201

159202
if !strings.HasPrefix(credSpecFile, "file://") {
160203
return errors.New("invalid credentialspec file specification")
161204
}
162205

163-
dockerHostconfigSecOptCredSpec := strings.Replace(credentialspec, "credentialspec:", "credentialspec=", 1)
206+
if credSpecPrefix == "credentialspecdomainless:" {
207+
relativeFilePath := strings.TrimPrefix(credSpecFile, "file://")
208+
dir, originalFileName := filepath.Split(relativeFilePath)
209+
210+
// Generate unique filename using taskId, containerName, credspecfile original name
211+
taskId, err := utils.TaskIdFromArn(cs.taskARN)
212+
if err != nil {
213+
cs.setTerminalReason(err.Error())
214+
return err
215+
}
216+
containerName, ok := cs.credentialSpecContainerMap[credentialspec]
217+
if !ok {
218+
return errors.New(fmt.Sprintf("Unable to retrieve containerName from credentialSpecContainerMap. No such key %s", credentialspec))
219+
}
220+
221+
// We need a different outfile in order to avoid modifying the customers original credentialspec
222+
outFile := fmt.Sprintf("%s_%s_%s", taskId, containerName, originalFileName)
223+
credSpecFile = "file://" + filepath.Join(dir, outFile)
224+
225+
// Fill in appropriate domainless gMSA fields
226+
err = readWriteDomainlessCredentialSpecImpl(filepath.Join(cs.credentialSpecResourceLocation, dir, originalFileName), filepath.Join(cs.credentialSpecResourceLocation, dir, outFile), cs.taskARN)
227+
if err != nil {
228+
cs.setTerminalReason(err.Error())
229+
return err
230+
}
231+
}
232+
233+
dockerHostconfigSecOptCredSpec := "credentialspec=" + credSpecFile
164234
cs.updateCredSpecMapping(credentialspec, dockerHostconfigSecOptCredSpec)
165235

166236
return nil
@@ -207,6 +277,12 @@ func (cs *CredentialSpecResource) handleS3CredentialspecFile(originalCredentials
207277
return err
208278
}
209279

280+
err = handleNonFileDomainlessGMSACredSpecImpl(originalCredentialspec, localCredSpecFilePath, cs.taskARN)
281+
if err != nil {
282+
cs.setTerminalReason(err.Error())
283+
return err
284+
}
285+
210286
dockerHostconfigSecOptCredSpec := fmt.Sprintf("credentialspec=file://%s", filepath.Base(localCredSpecFilePath))
211287
cs.updateCredSpecMapping(originalCredentialspec, dockerHostconfigSecOptCredSpec)
212288

@@ -269,6 +345,13 @@ func (cs *CredentialSpecResource) handleSSMCredentialspecFile(originalCredential
269345
cs.setTerminalReason(err.Error())
270346
return err
271347
}
348+
349+
err = handleNonFileDomainlessGMSACredSpecImpl(originalCredentialspec, localCredSpecFilePath, cs.taskARN)
350+
if err != nil {
351+
cs.setTerminalReason(err.Error())
352+
return err
353+
}
354+
272355
dockerHostconfigSecOptCredSpec := fmt.Sprintf("credentialspec=file://%s", customCredSpecFileName)
273356
cs.updateCredSpecMapping(originalCredentialspec, dockerHostconfigSecOptCredSpec)
274357

@@ -317,11 +400,15 @@ func (cs *CredentialSpecResource) updateCredSpecMapping(credSpecInput, targetCre
317400
// Cleanup removes the credentialspec created for the task
318401
func (cs *CredentialSpecResource) Cleanup() error {
319402
cs.clearCredentialSpec()
403+
if cs.isDomainlessGMSATask {
404+
err := cs.deleteTaskExecutionCredentialsRegKeys()
405+
if err != nil {
406+
return err
407+
}
408+
}
320409
return nil
321410
}
322411

323-
var remove = os.Remove
324-
325412
// clearCredentialSpec cycles through the collection of credentialspec data and
326413
// removes them from the task
327414
func (cs *CredentialSpecResource) clearCredentialSpec() {
@@ -334,14 +421,14 @@ func (cs *CredentialSpecResource) clearCredentialSpec() {
334421
continue
335422
}
336423
// Split credentialspec to obtain local file-name
337-
credSpecSplit := strings.SplitAfterN(value, "credentialspec=file://", 2)
424+
credSpecSplit := strings.SplitAfterN(value, "file://", 2)
338425
if len(credSpecSplit) != 2 {
339426
seelog.Warnf("Unable to parse target credentialspec: %s", value)
340427
continue
341428
}
342429
localCredentialSpecFile := credSpecSplit[1]
343430
localCredentialSpecFilePath := filepath.Join(cs.credentialSpecResourceLocation, localCredentialSpecFile)
344-
err := remove(localCredentialSpecFilePath)
431+
err := osRemoveImpl(localCredentialSpecFilePath)
345432
if err != nil {
346433
seelog.Warnf("Unable to clear local credential spec file %s for task %s", localCredentialSpecFile, cs.taskARN)
347434
}
@@ -350,6 +437,33 @@ func (cs *CredentialSpecResource) clearCredentialSpec() {
350437
}
351438
}
352439

440+
func (cs *CredentialSpecResource) deleteTaskExecutionCredentialsRegKeys() error {
441+
cs.lock.Lock()
442+
defer cs.lock.Unlock()
443+
444+
return deleteTaskExecutionCredentialsRegKeysImpl(cs.taskARN)
445+
}
446+
447+
// deleteTaskExecutionCredentialsRegKeys deletes the taskExecutionRole IAM credentials in the task registry key
448+
// after the task has been terminated.
449+
func deleteTaskExecutionCredentialsRegKeys(taskARN string) error {
450+
k, err := registry.OpenKey(registry.LOCAL_MACHINE, ecsCcgPluginRegistryKeyRoot, registry.ALL_ACCESS)
451+
if err != nil {
452+
// Early exit with success case, if the registry key doesn't exist then there are no task execution role creds to cleanup
453+
seelog.Errorf("Error opening %s key: %s", ecsCcgPluginRegistryKeyRoot, err)
454+
return nil
455+
}
456+
defer k.Close()
457+
458+
err = registry.DeleteKey(k, taskARN)
459+
if err != nil {
460+
seelog.Errorf("Error deleting %s key: %s", ecsCcgPluginRegistryKeyRoot+"\\"+taskARN, err)
461+
return err
462+
}
463+
seelog.Infof("Deleted Task Execution Credential Registry key for task: %s", taskARN)
464+
return nil
465+
}
466+
353467
func (cs *CredentialSpecResource) setCredentialSpecResourceLocation() error {
354468
// TODO: Use registry to setup credentialspec resource location
355469
// This should always be available on Windows instances
@@ -378,3 +492,148 @@ func (cs *CredentialSpecResource) MarshallPlatformSpecificFields(credentialSpecR
378492
func (cs *CredentialSpecResource) UnmarshallPlatformSpecificFields(credentialSpecResourceJSON CredentialSpecResourceJSON) {
379493
return
380494
}
495+
496+
// setTaskExecutionCredentialsRegKeys stores the taskExecutionRole IAM credentials to the task registry key
497+
// so that the domainless gMSA plugin may use these credentials to access the customer Active Directory authentication
498+
// information.
499+
func setTaskExecutionCredentialsRegKeys(taskCredentials credentials.IAMRoleCredentials, taskArn string) error {
500+
if taskCredentials == (credentials.IAMRoleCredentials{}) {
501+
err := errors.New("Unable to find execution role credentials while setting registry key for task " + taskArn)
502+
return err
503+
}
504+
505+
taskRegistryKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, ecsCcgPluginRegistryKeyRoot+"\\"+taskArn, registry.WRITE)
506+
if err != nil {
507+
errMsg := fmt.Sprintf("Error creating registry key root %s for task %s: %s", ecsCcgPluginRegistryKeyRoot, taskArn, err)
508+
seelog.Errorf(errMsg)
509+
return errors.Wrapf(err, errMsg)
510+
}
511+
defer taskRegistryKey.Close()
512+
513+
err = taskRegistryKey.SetStringValue("AKID", taskCredentials.AccessKeyID)
514+
if err != nil {
515+
errMsg := fmt.Sprintf("Error creating AKID child value for task %s:%s", taskArn, err)
516+
seelog.Errorf(errMsg)
517+
return errors.Wrapf(err, errMsg)
518+
}
519+
err = taskRegistryKey.SetStringValue("SKID", taskCredentials.SecretAccessKey)
520+
if err != nil {
521+
errMsg := fmt.Sprintf("Error creating AKID child value for task %s:%s", taskArn, err)
522+
seelog.Errorf(errMsg)
523+
return errors.Wrapf(err, errMsg)
524+
}
525+
err = taskRegistryKey.SetStringValue("SESSIONTOKEN", taskCredentials.SessionToken)
526+
if err != nil {
527+
errMsg := fmt.Sprintf("Error creating SESSIONTOKEN child value for task %s:%s", taskArn, err)
528+
seelog.Errorf(errMsg)
529+
return errors.Wrapf(err, errMsg)
530+
}
531+
532+
return nil
533+
}
534+
535+
// handleNonFileDomainlessGMSACredSpec reads and then injects the taskExecutionRoleRegistryKey location for
536+
// the s3/ssm gMSA credential spec cases.
537+
func handleNonFileDomainlessGMSACredSpec(originalCredSpec, localCredSpecFilePath, taskARN string) error {
538+
// Exit early for non domainless gMSA cred specs
539+
if !strings.HasPrefix(originalCredSpec, "credentialspecdomainless:") {
540+
return nil
541+
}
542+
543+
err := readWriteDomainlessCredentialSpecImpl(localCredSpecFilePath, localCredSpecFilePath, taskARN)
544+
if err != nil {
545+
return err
546+
}
547+
return nil
548+
}
549+
550+
// readWriteDomainlessCredentialSpec is used to open the credential spec file on local disk, inject the
551+
// taskExecutionRoleInformation in memory, and then write the file to a specific path. The reason we do not
552+
// modify the same fail is to avoid modifying the customer resource when the customer provides a local file
553+
// credential spec
554+
func readWriteDomainlessCredentialSpec(filePath, outFilePath, taskARN string) error {
555+
credSpec, err := readCredentialSpecImpl(filePath)
556+
if err != nil {
557+
return err
558+
}
559+
err = writeCredentialSpecImpl(credSpec, outFilePath, taskARN)
560+
if err != nil {
561+
return err
562+
}
563+
return nil
564+
}
565+
566+
// readCredentialSpec is used to open the credential spec file on local disk and read it into a generic
567+
// bytes map object map[string]interface{}
568+
func readCredentialSpec(filePath string) (map[string]interface{}, error) {
569+
byteResult, err := osReadFileImpl(filePath)
570+
if err != nil {
571+
return nil, err
572+
}
573+
var credSpec map[string]interface{}
574+
err = json.Unmarshal(byteResult, &credSpec)
575+
if err != nil {
576+
return nil, err
577+
}
578+
return credSpec, nil
579+
}
580+
581+
// writeCredentialSpec is used to selectively decode portions of the Microsoft gMSA generated credential spec file and then
582+
// inject the taskExecutionRoleRegistryKey location so that the gMSA plugin is able to access these IAM credentials.
583+
// The reason that the JSON unmarshalling is manual is to protect against future key/value pairs that appear in the JSON,
584+
// while only modifying the portions that pertain to domainless gMSA. This is in case Microsoft adds additional keys to the
585+
// JSON credential spec, so that our writer does not ignore this data.
586+
func writeCredentialSpec(credSpec map[string]interface{}, outFilePath string, taskARN string) error {
587+
activeDirectoryConfigUntyped, ok := credSpec["ActiveDirectoryConfig"]
588+
if !ok {
589+
return errors.New(fmt.Sprintf(credentialSpecParseErrorMsgTemplate, "ActiveDirectoryConfig"))
590+
}
591+
activeDirectoryConfig, ok := activeDirectoryConfigUntyped.(map[string]interface{})
592+
if !ok {
593+
return errors.New(fmt.Sprintf(untypedMarshallErrorMsgTemplate, "activeDirectoryConfigUntyped", "map[string]interface{}"))
594+
}
595+
596+
hostAccountConfigUntyped, ok := activeDirectoryConfig["HostAccountConfig"]
597+
if !ok {
598+
return errors.New(fmt.Sprintf(credentialSpecParseErrorMsgTemplate, "HostAccountConfig"))
599+
}
600+
hostAccountConfig, ok := hostAccountConfigUntyped.(map[string]interface{})
601+
if !ok {
602+
return errors.New(fmt.Sprintf(untypedMarshallErrorMsgTemplate, "hostAccountConfigUntyped", "map[string]interface{}"))
603+
}
604+
605+
pluginInputStringUntyped, ok := hostAccountConfig["PluginInput"]
606+
if !ok {
607+
return errors.New(fmt.Sprintf(credentialSpecParseErrorMsgTemplate, "PluginInput"))
608+
}
609+
var pluginInputParsed pluginInput
610+
pluginInputString, ok := pluginInputStringUntyped.(string)
611+
if !ok {
612+
return errors.New(fmt.Sprintf(untypedMarshallErrorMsgTemplate, "pluginInputStringUntyped", "string"))
613+
}
614+
err := json.Unmarshal([]byte(pluginInputString), &pluginInputParsed)
615+
if err != nil {
616+
return err
617+
}
618+
619+
pluginInputParsed.RegKeyPath = fmt.Sprintf(regKeyPathFormat, taskARN)
620+
621+
pluginInputBytes, err := json.Marshal(pluginInputParsed)
622+
if err != nil {
623+
return err
624+
}
625+
626+
hostAccountConfig["PluginInput"] = string(pluginInputBytes)
627+
628+
jsonBytes, err := json.Marshal(credSpec)
629+
if err != nil {
630+
return err
631+
}
632+
633+
err = osWriteFileImpl(outFilePath, jsonBytes, filePerm)
634+
if err != nil {
635+
return err
636+
}
637+
638+
return nil
639+
}

0 commit comments

Comments
 (0)