From 4aac2b6026d27d4eba660b674bdb1f34bcfbfc54 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Sat, 24 Dec 2022 08:30:27 -0800 Subject: Use Vault for IAM users --- app/controllers/api_account.go | 8 +-- app/controllers/aws.go | 51 ++++++++++++++- app/models/account.go | 12 ++-- app/models/session_key.go | 2 +- cloud/aws/aws.go | 137 +++++++++++++++-------------------------- cmd/web/server.go | 12 +++- go.mod | 5 +- go.sum | 9 +-- 8 files changed, 128 insertions(+), 108 deletions(-) diff --git a/app/controllers/api_account.go b/app/controllers/api_account.go index 815daf4..cecd334 100644 --- a/app/controllers/api_account.go +++ b/app/controllers/api_account.go @@ -2,14 +2,12 @@ package controllers import ( "context" - "fmt" "net/http" "time" "code.crute.us/mcrute/cloud-identity-broker/app" "code.crute.us/mcrute/cloud-identity-broker/app/middleware" "code.crute.us/mcrute/cloud-identity-broker/app/models" - "code.crute.us/mcrute/cloud-identity-broker/cloud/aws" glecho "code.crute.us/mcrute/golib/echo" "code.crute.us/mcrute/golib/echo/controller" @@ -90,7 +88,7 @@ func (h *APIAccountHandler) HandleGet(c echo.Context) error { // details about the account so they should only be visible to users who // can administer the account. if !a.CanBeModifiedBy(p) { - a.VaultMaterial = "" + a.AdminVaultMaterial = "" a.Users = nil } @@ -136,7 +134,7 @@ func (h *APIAccountHandler) HandlePut(c echo.Context) error { a.AccountNumber = in.AccountNumber a.Name = in.Name a.ConsoleSessionDuration = in.ConsoleSessionDuration - a.VaultMaterial = in.VaultMaterial + a.AdminVaultMaterial = in.AdminVaultMaterial a.DefaultRegion = in.DefaultRegion a.Users = in.Users @@ -181,12 +179,14 @@ func (h *APIAccountHandler) HandlePost(c echo.Context) error { } } + /* TODO: Validate that the vault material exists if err := aws.ValidateVaultMaterial(in.VaultMaterial); err != nil { return &echo.HTTPError{ Code: http.StatusBadRequest, Message: fmt.Sprintf("Unable to access Vault material: %s", err), } } + */ if err := h.Store.Put(context.Background(), &in); err != nil { return echo.ErrInternalServerError diff --git a/app/controllers/aws.go b/app/controllers/aws.go index 5b1765d..4f32942 100644 --- a/app/controllers/aws.go +++ b/app/controllers/aws.go @@ -2,11 +2,14 @@ package controllers import ( "context" + "sync" "code.crute.us/mcrute/cloud-identity-broker/app/middleware" "code.crute.us/mcrute/cloud-identity-broker/app/models" "code.crute.us/mcrute/cloud-identity-broker/cloud/aws" + "code.crute.us/mcrute/golib/secrets" + "github.com/labstack/echo/v4" ) @@ -20,7 +23,49 @@ type requestContext struct { // This capability does common permission checks and populates a request // context with user, account, and AWS API information. type AWSAPI struct { - Store models.AccountStore + Store models.AccountStore + Secrets secrets.Client + cache sync.Map // of aws.AWSClient +} + +func (h *AWSAPI) getClientFor(ctx context.Context, a *models.Account) (aws.AWSClient, error) { + var client aws.AWSClient + + if cv, ok := h.cache.Load(a.ShortName); !ok { + client, err := aws.NewAWSClientFromAccount(ctx, a, h.Secrets) + if err != nil { + return nil, err + } + + cv, _ = h.cache.LoadOrStore(a.ShortName, client) + client = cv.(aws.AWSClient) + } else { + client = cv.(aws.AWSClient) + } + + return client, nil +} + +// Preload enumerates all managed accounts and pre-loads all of the +// clients into the cache. +// +// This exists because there is replication delay of around 5-10 seconds +// for IAM users into other regions so these should be created as early +// in the process lifecycle as possible. +func (h *AWSAPI) Preload(ctx context.Context) []error { + accounts, err := h.Store.List(ctx) + if err != nil { + return []error{err} + } + + errors := []error{} + for _, a := range accounts { + _, err = h.getClientFor(ctx, a) + if err != nil { + errors = append(errors, err) + } + } + return errors } // GetContext checks that the user is authenticated and is authorized to access @@ -38,7 +83,7 @@ func (h *AWSAPI) GetContext(c echo.Context) (*requestContext, error) { return nil, echo.NotFoundHandler(c) } - ac, err := aws.NewAWSClientFromAccount(account) + client, err := h.getClientFor(c.Request().Context(), account) if err != nil { c.Logger().Errorf("Error building AWS client: %w", err) return nil, echo.ErrInternalServerError @@ -47,6 +92,6 @@ func (h *AWSAPI) GetContext(c echo.Context) (*requestContext, error) { return &requestContext{ Account: account, Principal: principal, - AWS: ac, + AWS: client, }, nil } diff --git a/app/models/account.go b/app/models/account.go index af29c3e..346354c 100644 --- a/app/models/account.go +++ b/app/models/account.go @@ -25,8 +25,9 @@ type Account struct { AccountType string `json:"account_type"` AccountNumber int `json:"account_number"` Name string `json:"name"` - ConsoleSessionDuration time.Duration `json:"console_session_duration, omitempty"` - VaultMaterial string `json:"vault_material,omitempty"` + ConsoleSessionDuration time.Duration `json:"console_session_duration,omitempty"` + AdminVaultMaterial string `json:"admin_vault_material,omitempty"` + AssumedRoleARN string `json:"assumed_role_arn"` DefaultRegion string `json:"default_region"` Users []string `json:"users,omitempty"` Deleted *time.Time `json:"deleted,omitempty" bson:"deleted,omitempty"` @@ -43,10 +44,9 @@ func (a *Account) CanBeModifiedBy(u *User) bool { type MongoDbAccountStore struct { Db *mongodb.Mongo - // ReturnDeleted will allow all methods to return deleted items. By default - // items where the Deleted field is set will not be returned. This should - // be the common cast for most code using this store but in some Admin - // use-cases it would be useful to show deleted accounts. + // ReturnDeleted will allow all methods to return deleted items. items + // where the Deleted field is set will not be returned. Non-admin + // use-cases should leave this set to false. ReturnDeleted bool } diff --git a/app/models/session_key.go b/app/models/session_key.go index c8b327e..b75d6c4 100644 --- a/app/models/session_key.go +++ b/app/models/session_key.go @@ -52,7 +52,7 @@ type SessionKey struct { NotBefore *time.Time PublicKey crypto.PublicKey PrivateKey *ecdsa.PrivateKey - ExposePrivateKeysInJSON bool `"-" json:"-" bson:"-"` + ExposePrivateKeysInJSON bool `json:"-" bson:"-"` } func GenerateSessionKey(ttl time.Duration) (*SessionKey, error) { diff --git a/cloud/aws/aws.go b/cloud/aws/aws.go index 36ac338..3229dd2 100644 --- a/cloud/aws/aws.go +++ b/cloud/aws/aws.go @@ -1,6 +1,7 @@ package aws import ( + "context" "encoding/json" "fmt" "io/ioutil" @@ -9,9 +10,10 @@ import ( "strconv" "code.crute.us/mcrute/cloud-identity-broker/app/models" + "code.crute.us/mcrute/golib/secrets" - "code.crute.us/mcrute/golib/vault" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" @@ -27,28 +29,18 @@ type Region struct { Enabled bool } +// AWSClient is a client for working with the AWS APIs. +// +// Instances of AWSClient are safe for concurrent access. type AWSClient interface { AssumeRole(string, *string) (*sts.Credentials, error) GetFederationURL(string, string) (string, error) GetRegionList() ([]*Region, error) } -// account models the account configuration stored in Vault for an AWS account -// with assumable roles that are stored within a kv JSON record. -type account struct { - AccessKeyId string - SecretAccessKey string - Roles map[string]struct { - ARN string - ExternalId string - } -} - type client struct { - AccessKeyId string - SecretAccessKey string + Credentials credentials.Value ARN string - ExternalId string ConsoleSessionDurationSecs int64 } @@ -62,61 +54,22 @@ var _ AWSClient = (*client)(nil) // which is used as the scope for this AWS client. Thus even if an account has // multiple roles there must be one instance of the AWS client per account/role // pair. -func NewAWSClientFromAccount(a *models.Account) (AWSClient, error) { - var ac account - if err := vault.GetVaultKeyStruct(a.VaultMaterial, &ac); err != nil { +func NewAWSClientFromAccount(ctx context.Context, a *models.Account, sc secrets.Client) (AWSClient, error) { + u, _, err := sc.AWSIAMUser(ctx, a.AdminVaultMaterial) + if err != nil { return nil, err } - r, ok := ac.Roles[a.ShortName] - if !ok { - return nil, fmt.Errorf("No roles for account %s in vault response", a.ShortName) - } - return &client{ - AccessKeyId: ac.AccessKeyId, - SecretAccessKey: ac.SecretAccessKey, - ARN: r.ARN, - ExternalId: r.ExternalId, + Credentials: credentials.Value{ + AccessKeyID: u.AccessKeyId, + SecretAccessKey: u.SecretAccessKey, + }, + ARN: a.AssumedRoleARN, ConsoleSessionDurationSecs: a.ConsoleSessionDurationSecs(), }, nil } -// ValidateVaultMaterial is used to check that a Vault material can be accessed -// and that the shape of that material is correct for an AWS access key and -// role list. -// -// This should be used for admission control for the creation of new accounts. -func ValidateVaultMaterial(m string) error { - var ac account - if err := vault.GetVaultKeyStruct(m, &ac); err != nil { - return fmt.Errorf("Unable to access vault material: %w", err) - } - - if ac.AccessKeyId == "" { - return fmt.Errorf("AccessKeyId is empty") - } - - if ac.SecretAccessKey == "" { - return fmt.Errorf("SecretAccessKey is empty") - } - - if len(ac.Roles) == 0 { - return fmt.Errorf("No roles specified") - } - - for k, r := range ac.Roles { - if r.ARN == "" { - return fmt.Errorf("ARN for role %s is empty", k) - } - if r.ExternalId == "" { - return fmt.Errorf("ExternalId for role %s is empty", k) - } - } - - return nil -} - // AssumeRole uses an IAM user credential with higher privilege to assume a // role in an AWS account and region. It returns the STS credentials. // @@ -124,32 +77,34 @@ func ValidateVaultMaterial(m string) error { // regions AWS has been siloing assumed role credentials to that region so it's // important to use the correct regional endpoint to fetch the credentials. // -// Note that this is not simply a passthrough to Vault's AWS backend because -// the Vault backend works by assuming roles and when assuming roles with an -// assumed role AWS limits the chained role lifetime to 1 hour which doesn't -// work depending on how the upstream web application tied to this client -// works. This method instead uses a long-lived IAM user credential to assume a -// role, which has a limited lifetime which is typically greater than 1 hour. +// Note that this is not simply a passthrough to Vault's AWS backend +// because the Vault backend will only call AssumeRole within the region +// of the root account configured in Vault and clients of this method +// need to be able to make the correct calls in the region of their +// choice. func (a *client) AssumeRole(user string, region *string) (*sts.Credentials, error) { if region != nil && *region == "global" { region = nil } - c := sts.New(session.New(&aws.Config{ - Region: region, - Credentials: credentials.NewStaticCredentials( - a.AccessKeyId, - a.SecretAccessKey, - "", - ), - })) - result, err := c.AssumeRole(&sts.AssumeRoleInput{ - ExternalId: aws.String(a.ExternalId), + s, err := session.NewSessionWithOptions(session.Options{ + Config: aws.Config{ + Region: region, + Credentials: credentials.NewStaticCredentialsFromCreds(a.Credentials), + }, + SharedConfigState: session.SharedConfigDisable, + }) + if err != nil { + return nil, err + } + + result, err := sts.New(s).AssumeRole(&sts.AssumeRoleInput{ RoleArn: aws.String(a.ARN), RoleSessionName: aws.String(user), DurationSeconds: aws.Int64(a.ConsoleSessionDurationSecs), }) if err != nil { + fmt.Printf("AWS AssumeRole Error: %s\n", err.(awserr.Error).Message()) return nil, err } @@ -167,16 +122,24 @@ func (a *client) GetRegionList() ([]*Region, error) { return nil, err } - ec2c := ec2.New(session.New(&aws.Config{ - Region: defaultRegion, - Credentials: credentials.NewStaticCredentials( - *r.AccessKeyId, - *r.SecretAccessKey, - *r.SessionToken, - ), - })) + s, err := session.NewSessionWithOptions(session.Options{ + Config: aws.Config{ + Region: defaultRegion, + Credentials: credentials.NewStaticCredentials( + *r.AccessKeyId, + *r.SecretAccessKey, + *r.SessionToken, + ), + }, + SharedConfigState: session.SharedConfigDisable, + }) + if err != nil { + return nil, err + } - ro, err := ec2c.DescribeRegions(&ec2.DescribeRegionsInput{AllRegions: aws.Bool(true)}) + ro, err := ec2.New(s).DescribeRegions(&ec2.DescribeRegionsInput{ + AllRegions: aws.Bool(true), + }) if err != nil { return nil, err } diff --git a/cmd/web/server.go b/cmd/web/server.go index 9c29544..82e0f75 100644 --- a/cmd/web/server.go +++ b/cmd/web/server.go @@ -130,7 +130,17 @@ func setupApplication(ctx context.Context, cfg app.Config, s *glecho.EchoWrapper as := &models.MongoDbAccountStore{Db: mongo} us := &models.MongoDbUserStore{Db: mongo} - aws := &controllers.AWSAPI{Store: as} + aws := &controllers.AWSAPI{ + Store: as, + Secrets: vc, + } + + if errs := aws.Preload(ctx); len(errs) > 0 { + for _, err := range errs { + log.Printf("Error preloading AWS accounts: %s", err) + } + log.Fatalf("Could not preload all AWS accounts") + } ghCred := &app.GitHubOauthCreds{} if _, err := vc.Secret(ctx, cfg.GitHubOauthCreds, &ghCred); err != nil { diff --git a/go.mod b/go.mod index b1202d4..b86cee7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module code.crute.us/mcrute/cloud-identity-broker go 1.18 replace ( - code.crute.us/mcrute/golib => ../golib + code.crute.us/mcrute/golib/secrets => ../golib/secrets golang.org/x/crypto => ../third_party/golang/x/crypto ) @@ -14,7 +14,6 @@ require ( code.crute.us/mcrute/golib/db/mongodb/v2 v2.0.0 code.crute.us/mcrute/golib/echo v0.9.3 code.crute.us/mcrute/golib/secrets v0.2.0 - code.crute.us/mcrute/golib/vault v0.2.4 github.com/aws/aws-sdk-go v1.42.4 github.com/labstack/echo/v4 v4.6.1 github.com/prometheus/client_golang v1.11.0 @@ -29,6 +28,7 @@ require ( code.crute.us/mcrute/golib v0.5.1 // indirect code.crute.us/mcrute/golib/clients/dns v0.1.0 // indirect code.crute.us/mcrute/golib/clients/netbox v0.1.0 // indirect + code.crute.us/mcrute/golib/vault v0.2.4 // indirect github.com/armon/go-metrics v0.3.10 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -96,4 +96,5 @@ require ( google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect google.golang.org/grpc v1.42.0 // indirect google.golang.org/protobuf v1.27.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 4864035..038cea4 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +code.crute.us/mcrute/golib v0.5.1 h1:z66+VQxqas98nPYeGEdJvkHrQwxd2mISVm6qzaBJwF8= +code.crute.us/mcrute/golib v0.5.1/go.mod h1:dukLPhs1H8dxtkhXtpJZYo/bMzefLRbdRj9Tj67wdaQ= code.crute.us/mcrute/golib/cli v0.2.2 h1:1MgyEYCyZ2oJBs/FrztMmxJoh0v+7j21VsWXBTIWsqw= code.crute.us/mcrute/golib/cli v0.2.2/go.mod h1:vc2TpQ5J/3zRfcWq6sclmU0EmJI8xygpOij77VJ8EK8= code.crute.us/mcrute/golib/clients/autocert/v2 v2.0.0 h1:MTS65Npib7DFnsNZ5Fs7EYXkK2ITEqdZQ18kBd3FdPk= @@ -59,8 +61,6 @@ code.crute.us/mcrute/golib/db/mongodb/v2 v2.0.0 h1:v4AYsbesoDeAMMbwS43WzqywNm0w0 code.crute.us/mcrute/golib/db/mongodb/v2 v2.0.0/go.mod h1:3dFJwm2MtCb312eHdHnK/w8D1lwgCeewa/2hztw89kE= code.crute.us/mcrute/golib/echo v0.9.3 h1:rg14mTarLpRrpHPJN5X1k1VTFplo3rgOPCXPRf3xzmY= code.crute.us/mcrute/golib/echo v0.9.3/go.mod h1:mcmhqsSWD/+ECdrd0Sh9u9XGtukXdLPVHc88sKg/gJo= -code.crute.us/mcrute/golib/secrets v0.2.0 h1:ENPZ+GEkdcmbFak0kHrI9JdSmVmBHxdzoKnjgvPHf5E= -code.crute.us/mcrute/golib/secrets v0.2.0/go.mod h1:O1ypm8JirXI4SekwNCHwQbfsieDQJxeRNwZYoot6fvw= code.crute.us/mcrute/golib/vault v0.2.4 h1:lNc1hq26e/UAGBqxQlZiFffOXZSNEcEkKUzU3oRJ8Eg= code.crute.us/mcrute/golib/vault v0.2.4/go.mod h1:23C5g8O0zaeFfo7v6sCO0RKgnHIiHM9ku+ASOWHJD9k= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -515,8 +515,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= @@ -989,6 +989,7 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= @@ -1004,8 +1005,8 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -- cgit v1.2.3