From 3a5a7e108d9b20f7ef6a7e4bb8439f6e2ba65fa5 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Sun, 21 Nov 2021 20:54:22 -0800 Subject: Add cloud account CRUD endpoints --- app/controllers/api_account.go | 207 ++++++++++++++++++++++++++++++++++++ app/controllers/api_account_list.go | 2 + app/models/account.go | 95 +++++++++++------ cloud/aws/aws.go | 35 ++++++ cmd/web/server.go | 8 ++ go.mod | 4 +- go.sum | 8 +- 7 files changed, 322 insertions(+), 37 deletions(-) create mode 100644 app/controllers/api_account.go diff --git a/app/controllers/api_account.go b/app/controllers/api_account.go new file mode 100644 index 0000000..259a7d4 --- /dev/null +++ b/app/controllers/api_account.go @@ -0,0 +1,207 @@ +package controllers + +import ( + "context" + "fmt" + "net/http" + "time" + + "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" + "github.com/labstack/echo/v4" +) + +type APIAccountHandler struct { + Store models.AccountStore + AdminStore models.AccountStore +} + +func (h *APIAccountHandler) Register(prefix string, r glecho.URLRouter, mw ...echo.MiddlewareFunc) { + // This resource did not exist in the V1 API and thus has no V1 + // representation. We use the default handlers for V1 because otherwise + // requests with V1 Accept headers would result in 406 Unacceptable errors. + gh := &controller.ContentTypeNegotiatingHandler{ + DefaultHandler: h.HandleGet, + Handlers: map[string]echo.HandlerFunc{ + contentTypeV1: h.HandleGet, + contentTypeV2: h.HandleGet, + }, + } + r.GET(prefix, gh.Handle, mw...) + + ph := &controller.ContentTypeNegotiatingHandler{ + DefaultHandler: h.HandlePut, + Handlers: map[string]echo.HandlerFunc{ + contentTypeV1: h.HandlePut, + contentTypeV2: h.HandlePut, + }, + } + r.PUT(prefix, ph.Handle, mw...) + + poh := &controller.ContentTypeNegotiatingHandler{ + DefaultHandler: h.HandlePost, + Handlers: map[string]echo.HandlerFunc{ + contentTypeV1: h.HandlePost, + contentTypeV2: h.HandlePost, + }, + } + r.POST(prefix, poh.Handle, mw...) + + r.DELETE(prefix, h.HandleDelete, mw...) +} + +func (h *APIAccountHandler) getPrincipalAndAccount(c echo.Context) (*models.User, *models.Account, error) { + var err error + ctx := context.Background() + + p, err := middleware.GetAuthorizedPrincipal(c) + if err != nil { + return nil, nil, echo.ErrUnauthorized + } + + var a *models.Account + if p.IsAdmin { + a, err = h.AdminStore.GetForUser(ctx, c.Param("account"), p) + if err != nil { + return nil, nil, echo.NotFoundHandler(c) + } + } else { + a, err = h.Store.GetForUser(ctx, c.Param("account"), p) + if err != nil { + return nil, nil, echo.NotFoundHandler(c) + } + } + + return p, a, nil +} + +func (h *APIAccountHandler) HandleGet(c echo.Context) error { + p, a, err := h.getPrincipalAndAccount(c) + if err != nil { + return err + } + + // These fields are slightly sensitive and give away too many security + // details about the account so they should only be visible to users who + // can administer the account. + if !a.CanBeModifiedBy(p) { + a.VaultMaterial = "" + a.Users = nil + } + + return c.JSON(http.StatusOK, a) +} + +func (h *APIAccountHandler) HandlePut(c echo.Context) error { + var in models.Account + if err := c.Echo().JSONSerializer.Deserialize(c, &in); err != nil { + return echo.ErrBadRequest + } + + p, a, err := h.getPrincipalAndAccount(c) + if err != nil { + return err + } + + if !a.CanBeModifiedBy(p) { + return echo.ErrForbidden + } + + if in.ShortName != a.ShortName { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "Account short_name can not be changed. Create a new account.", + } + } + + if in.AccountType != a.AccountType { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "Account type can not be changed. Create a new account.", + } + } + + a.AccountNumber = in.AccountNumber + a.Name = in.Name + a.ConsoleSessionDuration = in.ConsoleSessionDuration + a.VaultMaterial = in.VaultMaterial + a.DefaultRegion = in.DefaultRegion + a.Users = in.Users + + // PUT-ing Deleted equal to null effectively un-deletes the record + a.Deleted = in.Deleted + + if err := h.Store.Put(context.Background(), a); err != nil { + return echo.ErrInternalServerError + } + + return c.String(http.StatusNoContent, "") +} + +func (h *APIAccountHandler) HandlePost(c echo.Context) error { + var in models.Account + if err := c.Echo().JSONSerializer.Deserialize(c, &in); err != nil { + return echo.ErrBadRequest + } + + if _, err := h.AdminStore.Get(context.Background(), in.ShortName); err == nil { + return &echo.HTTPError{ + Code: http.StatusConflict, + Message: "Account with short name already exists. Choose another short name.", + } + } + + if in.ConsoleSessionDuration < time.Hour { + in.ConsoleSessionDuration = time.Hour + } + + if in.ConsoleSessionDuration > 12*time.Hour { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "Console duration is greater than the AWS maximum of 12 hours.", + } + } + + if in.Deleted != nil { + return &echo.HTTPError{ + Code: http.StatusBadRequest, + Message: "Can not create deleted account, set Deleted to null", + } + } + + 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 + } + + c.Response().Header().Add("Location", glecho.URLFor(c, "/api/account", in.ShortName).String()) + + return c.String(http.StatusCreated, "") +} + +func (h *APIAccountHandler) HandleDelete(c echo.Context) error { + p, a, err := h.getPrincipalAndAccount(c) + if err != nil { + return err + } + + if a.CanBeModifiedBy(p) { + if err := h.Store.Delete(context.Background(), a); err != nil { + return echo.ErrInternalServerError + } + } else { + return echo.ErrForbidden + } + + return c.String(http.StatusNoContent, "") +} diff --git a/app/controllers/api_account_list.go b/app/controllers/api_account_list.go index f69db6a..28b64c1 100644 --- a/app/controllers/api_account_list.go +++ b/app/controllers/api_account_list.go @@ -17,6 +17,7 @@ type jsonAccount struct { AccountNumber int `json:"account_number"` ShortName string `json:"short_name"` Name string `json:"name"` + SelfUrl string `json:"url"` ConsoleUrl string `json:"get_console_url,omitempty"` ConsoleRedirectUrl string `json:"console_redirect_url,omitempty"` CredentialsUrl string `json:"credentials_url"` @@ -28,6 +29,7 @@ func jsonAccountFromAccount(c echo.Context, a *models.Account) *jsonAccount { AccountNumber: a.AccountNumber, ShortName: a.ShortName, Name: a.Name, + SelfUrl: glecho.URLFor(c, "/api/account", a.ShortName).String(), ConsoleUrl: glecho.URLFor(c, "/api/account", a.ShortName, "console").String(), ConsoleRedirectUrl: glecho.URLFor(c, "/api/account", a.ShortName, "console").Query("redirect", "1").String(), CredentialsUrl: glecho.URLFor(c, "/api/account", a.ShortName, "credentials").String(), diff --git a/app/models/account.go b/app/models/account.go index 0ae1821..61b144d 100644 --- a/app/models/account.go +++ b/app/models/account.go @@ -2,10 +2,11 @@ package models import ( "context" - "fmt" "time" "code.crute.us/mcrute/golib/db/mongodb" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" ) const accountCol = "accounts" @@ -20,43 +21,48 @@ type AccountStore interface { } type Account struct { - ShortName string `bson:"_id"` - AccountType string - AccountNumber int - Name string - ConsoleSessionDuration time.Duration - VaultMaterial string - DefaultRegion string - Users []string + ShortName string `bson:"_id" json:"short_name"` + 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"` + DefaultRegion string `json:"default_region"` + Users []string `json:"users,omitempty"` + Deleted *time.Time `json:"deleted,omitempty" bson:"deleted,omitempty"` } func (a *Account) ConsoleSessionDurationSecs() int64 { return int64(a.ConsoleSessionDuration.Seconds()) } -func (a *Account) CanAccess(u *User) bool { - if u.IsAdmin { - return true - } - // Linear search should be fine for now, these lists are pretty small - for _, n := range a.Users { - if n == u.Username { - return true - } - } - return false +func (a *Account) CanBeModifiedBy(u *User) bool { + return u.IsAdmin } 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 bool } // List returns all accounts in the system. func (s *MongoDbAccountStore) List(ctx context.Context) ([]*Account, error) { var out []*Account - if err := s.Db.FindAll(ctx, accountCol, &out); err != nil { + + filter := bson.M{} + if !s.ReturnDeleted { + filter["deleted"] = primitive.Null{} + } + + if err := s.Db.FindAllByFilter(ctx, accountCol, filter, &out); err != nil { return nil, err } + return out, nil } @@ -68,34 +74,56 @@ func (s *MongoDbAccountStore) List(ctx context.Context) ([]*Account, error) { // just use List directly. func (s *MongoDbAccountStore) ListForUser(ctx context.Context, u *User) ([]*Account, error) { var out []*Account - filter := mongodb.AnyInTopLevelArray("Users", u.Username) + + filter := mongodb.AnyInTopLevelArray("users", u.Username) + if !s.ReturnDeleted { + filter["deleted"] = primitive.Null{} + } + if err := s.Db.FindAllByFilter(ctx, accountCol, filter, &out); err != nil { return nil, err } + return out, nil } func (s *MongoDbAccountStore) Get(ctx context.Context, id string) (*Account, error) { var a Account - if err := s.Db.FindOneById(ctx, accountCol, id, &a); err != nil { + + filter := bson.M{"_id": id} + if !s.ReturnDeleted { + filter["deleted"] = primitive.Null{} + } + + if err := s.Db.FindOneByFilter(ctx, accountCol, filter, &a); err != nil { return nil, err } + return &a, nil } // GetForUser returns an account if the user has access to this account, // otherwise it returns an error. This is the authorized version of Get. func (s *MongoDbAccountStore) GetForUser(ctx context.Context, id string, u *User) (*Account, error) { - a, err := s.Get(ctx, id) - if err != nil { - return nil, err + var a Account + var filter bson.M + + if u.IsAdmin { + filter = bson.M{"_id": id} + } else { + filter = mongodb.AnyInTopLevelArray("users", u.Username) + filter["_id"] = id } - if !a.CanAccess(u) { - return nil, fmt.Errorf("User does not have access to account") + if !s.ReturnDeleted { + filter["deleted"] = primitive.Null{} } - return a, nil + if err := s.Db.FindOneByFilter(ctx, accountCol, filter, &a); err != nil { + return nil, err + } + + return &a, nil } func (s *MongoDbAccountStore) Put(ctx context.Context, a *Account) error { @@ -106,10 +134,15 @@ func (s *MongoDbAccountStore) Put(ctx context.Context, a *Account) error { } func (s *MongoDbAccountStore) Delete(ctx context.Context, a *Account) error { - if err := s.Db.DeleteOneById(ctx, accountCol, a.ShortName); err != nil { + a, err := s.Get(ctx, a.ShortName) + if err != nil { return err } - return nil + + now := time.Now() + a.Deleted = &now + + return s.Put(ctx, a) } var _ AccountStore = (*MongoDbAccountStore)(nil) diff --git a/cloud/aws/aws.go b/cloud/aws/aws.go index 180b2c4..36ac338 100644 --- a/cloud/aws/aws.go +++ b/cloud/aws/aws.go @@ -82,6 +82,41 @@ func NewAWSClientFromAccount(a *models.Account) (AWSClient, error) { }, 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. // diff --git a/cmd/web/server.go b/cmd/web/server.go index d13cd58..d2ea861 100644 --- a/cmd/web/server.go +++ b/cmd/web/server.go @@ -103,6 +103,10 @@ func webMain(cfg app.Config, embeddedTemplates fs.FS, version string) { ), ) + adminAccountStore := &models.MongoDbAccountStore{ + Db: mongo, + ReturnDeleted: true, + } as := &models.MongoDbAccountStore{Db: mongo} us := &models.MongoDbUserStore{Db: mongo} @@ -143,6 +147,10 @@ func webMain(cfg app.Config, embeddedTemplates fs.FS, version string) { controllers.NewAPICredentialsHandler(aws), rateLimit, ) + (&controllers.APIAccountHandler{ + Store: as, + AdminStore: adminAccountStore, + }).Register("/:account", api) } s.GET("/favicon.ico", echo.NotFoundHandler) s.GET("/logout", controllers.LogoutHandler) diff --git a/go.mod b/go.mod index c819322..df20f18 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.17 require ( code.crute.us/mcrute/golib v0.3.0 code.crute.us/mcrute/golib/cli v0.1.2 - code.crute.us/mcrute/golib/db/mongodb v0.2.0 - code.crute.us/mcrute/golib/echo v0.5.0 + code.crute.us/mcrute/golib/db/mongodb v0.3.0 + code.crute.us/mcrute/golib/echo v0.5.1 code.crute.us/mcrute/golib/vault v0.1.2 github.com/aws/aws-sdk-go v1.42.4 github.com/labstack/echo/v4 v4.6.1 diff --git a/go.sum b/go.sum index ffbddbe..3f63d02 100644 --- a/go.sum +++ b/go.sum @@ -41,10 +41,10 @@ code.crute.us/mcrute/golib v0.3.0 h1:7g45xUf/din4VZkKAIK+bPCfXVTlwnZYS4Jv+/lUQ74 code.crute.us/mcrute/golib v0.3.0/go.mod h1:VOnYQYqBYC3NUYPKwbzYSHW/BUBBU5RX7Z+A9nlJZUc= code.crute.us/mcrute/golib/cli v0.1.2 h1:Yeg+8Jcm5FSYxFvebIGGmDJqmaDCgxl6QPb0GTTMXi0= code.crute.us/mcrute/golib/cli v0.1.2/go.mod h1:qhim2CV3zsMflpCbTMJs7dKnzzVIdBkSvm4jHDyXgik= -code.crute.us/mcrute/golib/db/mongodb v0.2.0 h1:tumWZET3BgkutMWkeLLhIYy0c7dgEHa6mABF6oE1Y1o= -code.crute.us/mcrute/golib/db/mongodb v0.2.0/go.mod h1:JUX7PU8mUu68Y4sOERbZKON+x5A7cIxgxifxpXw//Bs= -code.crute.us/mcrute/golib/echo v0.5.0 h1:M8D69fCopxLee4rTYmPswNe2OVI7x7OmQOsr5P9nyUU= -code.crute.us/mcrute/golib/echo v0.5.0/go.mod h1:rNrjiYlJDwkabv0alUpSIsS/tR6HeBoP90UHaPUiL+Q= +code.crute.us/mcrute/golib/db/mongodb v0.3.0 h1:YBvVoFDqO1nqZFeYa1GwiCgkK8+LoQd03n61VV2AYWg= +code.crute.us/mcrute/golib/db/mongodb v0.3.0/go.mod h1:JUX7PU8mUu68Y4sOERbZKON+x5A7cIxgxifxpXw//Bs= +code.crute.us/mcrute/golib/echo v0.5.1 h1:YMQroWqNXlLzo6Zpz967F3rEyaO+5AFaiJ+PKLalFoA= +code.crute.us/mcrute/golib/echo v0.5.1/go.mod h1:rNrjiYlJDwkabv0alUpSIsS/tR6HeBoP90UHaPUiL+Q= code.crute.us/mcrute/golib/vault v0.1.1/go.mod h1:kr+P3q7WJ/+dKieQJ3ZMccWeWV0M3KyHu6Ofghn0H7U= code.crute.us/mcrute/golib/vault v0.1.2 h1:L80WffgReTtL8FUV83GRAYjPzhAQo4G+h1y1n3CkZEU= code.crute.us/mcrute/golib/vault v0.1.2/go.mod h1:kr+P3q7WJ/+dKieQJ3ZMccWeWV0M3KyHu6Ofghn0H7U= -- cgit v1.2.3