diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index d848976dd..4a9361cd6 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -20,6 +20,7 @@ import ( "go.step.sm/crypto/sshutil" "go.step.sm/crypto/x509util" + "github.com/smallstep/certificates/authority/provisioner/gcp" "github.com/smallstep/certificates/errs" "github.com/smallstep/certificates/webhook" ) @@ -71,6 +72,12 @@ func newGCPConfig() *gcpConfig { } } +// projectValidator is an interface to enable testing without using +// gcp.OrganizationProjectValidator. +type projectValidator interface { + ValidateProject(ctx context.Context, projectID string) error +} + // GCP is the provisioner that supports identity tokens created by the Google // Cloud Platform metadata API. // @@ -93,6 +100,7 @@ type GCP struct { Name string `json:"name"` ServiceAccounts []string `json:"serviceAccounts"` ProjectIDs []string `json:"projectIDs"` + OrganizationID string `json:"organizationID"` DisableCustomSANs bool `json:"disableCustomSANs"` DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"` DisableSSHCAUser *bool `json:"disableSSHCAUser,omitempty"` @@ -103,6 +111,7 @@ type GCP struct { config *gcpConfig keyStore *keyStore ctl *Controller + projectValidator projectValidator } // GetID returns the provisioner unique identifier. The name should uniquely @@ -224,6 +233,10 @@ func (p *GCP) Init(config Config) (err error) { return errors.New("provisioner instanceAge cannot be negative") } + if len(p.ProjectIDs) > 0 && p.OrganizationID != "" { + return errors.New("provisioner cannot have both `projectIDs` and `organizationID` set") + } + // Initialize config p.assertConfig() @@ -233,14 +246,22 @@ func (p *GCP) Init(config Config) (err error) { } config.Audiences = config.Audiences.WithFragment(p.GetIDForToken()) - p.ctl, err = NewController(p, p.Claims, config, p.Options) + + if p.ctl, err = NewController(p, p.Claims, config, p.Options); err != nil { + return + } + + if p.projectValidator, err = gcp.NewOrganizationValidator(context.Background(), p.ProjectIDs, p.OrganizationID); err != nil { + return + } + return } // AuthorizeSign validates the given token and returns the sign options that // will be used on certificate creation. func (p *GCP) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) { - claims, err := p.authorizeToken(token) + claims, err := p.authorizeToken(ctx, token) if err != nil { return nil, errs.Wrap(http.StatusInternalServerError, err, "gcp.AuthorizeSign") } @@ -315,7 +336,7 @@ func (p *GCP) assertConfig() { // authorizeToken performs common jwt authorization actions and returns the // claims for case specific downstream parsing. // e.g. a Sign request will auth/validate different fields than a Revoke request. -func (p *GCP) authorizeToken(token string) (*gcpPayload, error) { +func (p *GCP) authorizeToken(ctx context.Context, token string) (*gcpPayload, error) { jwt, err := jose.ParseSigned(token) if err != nil { return nil, errs.Wrap(http.StatusUnauthorized, err, "gcp.authorizeToken; error parsing gcp token") @@ -368,17 +389,8 @@ func (p *GCP) authorizeToken(token string) (*gcpPayload, error) { } // validate projects - if len(p.ProjectIDs) > 0 { - var found bool - for _, pi := range p.ProjectIDs { - if pi == claims.Google.ComputeEngine.ProjectID { - found = true - break - } - } - if !found { - return nil, errs.Unauthorized("gcp.authorizeToken; invalid gcp token - invalid project id") - } + if err := p.projectValidator.ValidateProject(ctx, claims.Google.ComputeEngine.ProjectID); err != nil { + return nil, err } // validate instance age @@ -414,7 +426,7 @@ func (p *GCP) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, return nil, err } - claims, err := p.authorizeToken(token) + claims, err := p.authorizeToken(ctx, token) if err != nil { return nil, errs.Wrap(http.StatusInternalServerError, err, "gcp.AuthorizeSSHSign") } diff --git a/authority/provisioner/gcp/projectvalidator.go b/authority/provisioner/gcp/projectvalidator.go new file mode 100644 index 000000000..92e9d6983 --- /dev/null +++ b/authority/provisioner/gcp/projectvalidator.go @@ -0,0 +1,76 @@ +package gcp + +import ( + "context" + "net/http" + + "github.com/smallstep/certificates/errs" + + "google.golang.org/api/cloudresourcemanager/v1" +) + +type ProjectValidator struct { + ProjectIDs []string +} + +func (p *ProjectValidator) ValidateProject(_ context.Context, projectID string) error { + if len(p.ProjectIDs) == 0 { + return nil + } + + for _, pi := range p.ProjectIDs { + if pi == projectID { + return nil + } + } + + return errs.Unauthorized("gcp.authorizeToken; invalid gcp token - invalid project id") +} + +type OrganizationValidator struct { + *ProjectValidator + + OrganizationID string + projectsService *cloudresourcemanager.ProjectsService +} + +func NewOrganizationValidator(ctx context.Context, projectIDs []string, organizationID string) (*OrganizationValidator, error) { + crm, err := cloudresourcemanager.NewService(ctx) + + if err != nil { + return nil, err + } + + return &OrganizationValidator{ + &ProjectValidator{projectIDs}, + organizationID, + crm.Projects, + }, nil +} + +func (p *OrganizationValidator) ValidateProject(ctx context.Context, projectID string) error { + if err := p.ProjectValidator.ValidateProject(ctx, projectID); err != nil { + return err + } + + ancestry, err := p.projectsService. + GetAncestry(projectID, &cloudresourcemanager.GetAncestryRequest{}). + Context(ctx). + Do() + + if err != nil { + return errs.Wrap(http.StatusInternalServerError, err, "gcp.authorizeToken") + } + + if len(ancestry.Ancestor) < 1 { + return errs.InternalServer("gcp.authorizeToken; getAncestry response malformed") + } + + progenitor := ancestry.Ancestor[len(ancestry.Ancestor)-1] + + if progenitor.ResourceId.Type != "organization" || progenitor.ResourceId.Id != p.OrganizationID { + return errs.Unauthorized("gcp.authorizeToken; invalid gcp token - project does not belong to organization") + } + + return nil +} diff --git a/authority/provisioner/gcp/projectvalidator_test.go b/authority/provisioner/gcp/projectvalidator_test.go new file mode 100644 index 000000000..9233a1ba8 --- /dev/null +++ b/authority/provisioner/gcp/projectvalidator_test.go @@ -0,0 +1,18 @@ +package gcp + +import ( + "context" + "testing" +) + +func TestProjectValidator(t *testing.T) { + validator := &ProjectValidator{ProjectIDs: []string{"allowed-1", "allowed-2"}} + + if err := validator.ValidateProject(context.Background(), "not-allowed"); err == nil { + t.Errorf("ProjectValidator.ValidateProject() = nil, want err") + } + + if err := validator.ValidateProject(context.Background(), "allowed-2"); err != nil { + t.Errorf("ProjectValidator.ValidateProject() = %v, want nil", err) + } +} diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index 2841f2e8e..37b031ec3 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -21,6 +21,7 @@ import ( "github.com/smallstep/assert" "github.com/smallstep/certificates/api/render" + "github.com/smallstep/certificates/authority/provisioner/gcp" ) func TestGCP_Getters(t *testing.T) { @@ -292,7 +293,7 @@ func TestGCP_authorizeToken(t *testing.T) { "fail/invalid-projectID": func(t *testing.T) test { p, err := generateGCP() assert.FatalError(t, err) - p.ProjectIDs = []string{"foo", "bar"} + p.projectValidator = &gcp.ProjectValidator{ProjectIDs: []string{"foo", "bar"}} tok, err := generateGCPToken(p.ServiceAccounts[0], "https://accounts.google.com", p.GetID(), "instance-id", "instance-name", "project-id", "zone", @@ -398,7 +399,7 @@ func TestGCP_authorizeToken(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { tc := tt(t) - if claims, err := tc.p.authorizeToken(tc.token); err != nil { + if claims, err := tc.p.authorizeToken(context.Background(), tc.token); err != nil { if assert.NotNil(t, tc.err) { var sc render.StatusCodedError assert.Fatal(t, errors.As(err, &sc), "error does not implement StatusCodedError interface") @@ -430,7 +431,7 @@ func TestGCP_AuthorizeSign(t *testing.T) { p3, err := generateGCP() assert.FatalError(t, err) - p3.ProjectIDs = []string{"other-project-id"} + p3.projectValidator = &gcp.ProjectValidator{ProjectIDs: []string{"other-project-id"}} p3.ServiceAccounts = []string{"foo@developer.gserviceaccount.com"} p3.InstanceAge = Duration{1 * time.Minute} diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index be7ac79a4..54840f138 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -21,6 +21,8 @@ import ( "go.step.sm/crypto/pemutil" "go.step.sm/crypto/randutil" "golang.org/x/crypto/ssh" + + "github.com/smallstep/certificates/authority/provisioner/gcp" ) var ( @@ -374,6 +376,7 @@ func generateGCP() (*GCP, error) { keySet: jose.JSONWebKeySet{Keys: []jose.JSONWebKey{*jwk}}, expiry: time.Now().Add(24 * time.Hour), }, + projectValidator: &gcp.ProjectValidator{}, } p.ctl, err = NewController(p, p.Claims, Config{ Audiences: testAudiences.WithFragment("gcp/" + name),