Skip to content

feat(gcp): enable organization validation #2133

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions authority/provisioner/gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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.
//
Expand All @@ -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"`
Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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")
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
76 changes: 76 additions & 0 deletions authority/provisioner/gcp/projectvalidator.go
Original file line number Diff line number Diff line change
@@ -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
}
18 changes: 18 additions & 0 deletions authority/provisioner/gcp/projectvalidator_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 4 additions & 3 deletions authority/provisioner/gcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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{"[email protected]"}
p3.InstanceAge = Duration{1 * time.Minute}

Expand Down
3 changes: 3 additions & 0 deletions authority/provisioner/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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),
Expand Down
Loading