Skip to content
This repository has been archived by the owner on Feb 16, 2023. It is now read-only.

GCP Linking #195

Merged
merged 22 commits into from
Jul 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
df0d165
Add oauthorizer package to deal with OAuth2 authorization flow
jpcoenen Jun 23, 2020
1a1f74d
Add IDP links client and API functionality
jpcoenen Jun 23, 2020
0f9a674
Add functions to extract project ID from a GCP SA email
jpcoenen Jun 23, 2020
14e18ae
Update test for new GCP SA functions
jpcoenen Jun 23, 2020
bf459e5
Refactor RequiredLinkedID to RequiredIDPLink
jpcoenen Jun 24, 2020
77b44b3
Add validation and error types for GCP IDP links
jpcoenen Jun 25, 2020
cc8cf8a
Add func to check whether the provided error is a known error
jpcoenen Jun 25, 2020
74355d0
Fix typo
jpcoenen Jun 25, 2020
6f305ba
Add extra error and replace status code
jpcoenen Jun 25, 2020
83c15e8
Add some comments to the IdentityProviderLink type
jpcoenen Jun 25, 2020
93f560c
Make sure WaitForAuthorizationCode() always returns
jpcoenen Jun 25, 2020
c6ae5c4
Fix typo
jpcoenen Jun 25, 2020
f7c68f7
Add Exists method for IDPLinks
jpcoenen Jun 26, 2020
ae4c5a5
Redirect user to customizable page after authorization
jpcoenen Jun 30, 2020
09e2017
Move oauthorizer to internals
jpcoenen Jun 30, 2020
9adfc0e
Remove oauthorizer dependency from api
jpcoenen Jun 30, 2020
16a22d4
Return that link exists if linking feature is disabled server-side
jpcoenen Jul 1, 2020
d465926
Return an iterator when listing GCP identity provider links
SimonBarendse Jul 2, 2020
ba04add
Validate input on creating GCP credential
jpcoenen Jul 2, 2020
9ba7647
Handle more error cases and return checkable errors
jpcoenen Jul 2, 2020
1a36573
Remove error argument from authorization callback
SimonBarendse Jul 2, 2020
2f54d22
Rename RedirectURL to ResultURL
jpcoenen Jul 2, 2020
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
18 changes: 18 additions & 0 deletions internals/api/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
Expand Down Expand Up @@ -224,6 +225,23 @@ func (req *CreateCredentialRequest) Validate() error {
return nil
}

// RequiredIDPLink can be used if the credential requires an IDP Link to exist before creation.
// It returns the link type and the linked ID if a link is required.
// It returns empty strings if no link is required for the credential type.
func (req *CreateCredentialRequest) RequiredIDPLink() (IdentityProviderLinkType, string, error) {
switch req.Type {
case CredentialTypeGCPServiceAccount:
serviceAccountEmail, ok := req.Metadata[CredentialMetadataGCPServiceAccountEmail]
if !ok {
return IdentityProviderLinkGCP, "", errors.New("missing required metadata")
}
projectID, err := ProjectIDFromGCPEmail(serviceAccountEmail)
return IdentityProviderLinkGCP, projectID, err
default:
return "", "", nil
}
}

// CredentialProofAWS is proof for when the credential type is AWSSTS.
type CredentialProofAWS struct {
Region string `json:"region"`
Expand Down
72 changes: 72 additions & 0 deletions internals/api/idp_link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package api

import (
"net/http"
"net/url"
"regexp"
"time"
)

var (
ErrInvalidIDPLinkType = errAPI.Code("invalid_idp_link_type").StatusError("invalid IDP link type", http.StatusBadRequest)
ErrInvalidGCPProjectID = errAPI.Code("invalid_gcp_project_id").StatusErrorPref("invalid GCP project ID: %s", http.StatusBadRequest)
ErrVerifyingGCPAccessProof = errAPI.Code("gcp_verification_error").StatusError("could not verify GCP authorization", http.StatusInternalServerError)
ErrInvalidGCPAuthorizationCode = errAPI.Code("invalid_authorization_code").StatusError("authorization code was not accepted by GCP", http.StatusPreconditionFailed)
ErrGCPLinkPermissionDenied = errAPI.Code("gcp_permission_denied").StatusError("missing required projects.get permission to create link to GCP project", http.StatusPreconditionFailed)

gcpProjectIDPattern = regexp.MustCompile("^[a-z][a-z0-9-]*[a-z0-9]$")
)

type CreateIdentityProviderLinkGCPRequest struct {
RedirectURL string `json:"redirect_url"`
AuthorizationCode string `json:"authorization_code"`
}

type IdentityProviderLinkType string

const (
IdentityProviderLinkGCP IdentityProviderLinkType = "gcp"
)

// IdentityProviderLink is a prerequisite for creating some identity provider backed service accounts.
// These links prove that a namespace's member has access to a resource (identified by the LinkedID) within
// the identity provider. Once a link between a namespace and an identity provider has been created, from then on
// service accounts can be created within the scope described by the LinkedID. For example, after creating a link
// to a GCP Project, GCP service accounts within that project can be used for the GCP Identity Provider.
//
// The meaning of LinkedID depends on the type of the IdentityProviderLink in the following way:
// - GCP: LinkedID is a GCP Project ID.
type IdentityProviderLink struct {
SimonBarendse marked this conversation as resolved.
Show resolved Hide resolved
Type IdentityProviderLinkType `json:"type"`
Namespace string `json:"namespace"`
LinkedID string `json:"linked_id"`
CreatedAt time.Time `json:"created_at"`
}

type OAuthConfig struct {
ClientID string `json:"client_id"`
AuthURI string `json:"auth_uri"`
Scopes []string `json:"scopes"`
ResultURL *url.URL `json:"result_url"`
}

// ValidateLinkedID calls the validation function corresponding to the link type and returns the corresponding result.
func ValidateLinkedID(linkType IdentityProviderLinkType, linkedID string) error {
switch linkType {
case IdentityProviderLinkGCP:
return ValidateGCPProjectID(linkedID)
default:
return ErrInvalidIDPLinkType
}
}

// ValidateGCPProjectID returns an error if the provided value is not a valid GCP project ID.
func ValidateGCPProjectID(projectID string) error {
if len(projectID) < 6 || len(projectID) > 30 {
return ErrInvalidGCPProjectID("length must be 6 to 30 character")
}
if !gcpProjectIDPattern.MatchString(projectID) {
return ErrInvalidGCPProjectID("can only contains lowercase letter, digits and hyphens")
}
return nil
}
40 changes: 32 additions & 8 deletions internals/api/patterns.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const (
// REGEX builder with unit tests: https://regex101.com/r/5DPAiZ/1
patternFullName = `[\p{L}\p{Mn}\p{Pd}\'\x{2019} ]`
patternDescription = `^[\p{L}\p{Mn}\p{Pd}\x{2019} [:punct:]0-9]{0,144}$`

gcpServiceAccountEmailSuffix = ".gserviceaccount.com"
gcpUserManagedServiceAccountEmailSuffix = ".iam.gserviceaccount.com"
)

var (
Expand Down Expand Up @@ -82,6 +85,10 @@ var (
"credential fingerprint must consist of 64 hexadecimal characters",
http.StatusBadRequest,
)

ErrInvalidGCPServiceAccountEmail = errAPI.Code("invalid_service_account_email").StatusError("not a valid GCP service account email", http.StatusBadRequest)
ErrNotUserManagerGCPServiceAccountEmail = errAPI.Code("require_user_managed_service_account").StatusError("provided GCP service account email is not for a user-manager service account", http.StatusBadRequest)
ErrInvalidGCPKMSResourceID = errAPI.Code("invalid_key_resource_id").StatusError("not a valid resource ID, expected: projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/KEY", http.StatusBadRequest)
)

// ValidateNamespace validates a username.
Expand Down Expand Up @@ -299,32 +306,49 @@ func ValidateShortCredentialFingerprint(fingerprint string) error {
// accepted by GCP.
func ValidateGCPServiceAccountEmail(v string) error {
if !govalidator.IsEmail(v) {
return errors.New("invalid email")
return ErrInvalidGCPServiceAccountEmail
}
if !strings.HasSuffix(v, gcpServiceAccountEmailSuffix) {
return ErrInvalidGCPServiceAccountEmail
}
if !strings.HasSuffix(v, ".gserviceaccount.com") {
return errors.New("not a GCP Service Account email")
if !strings.HasSuffix(v, gcpUserManagedServiceAccountEmailSuffix) {
return ErrNotUserManagerGCPServiceAccountEmail
}
return nil
}

// ProjectIDFromGCPEmail returns the project ID included in the email of a GCP Service Account.
// If the input is not a valid user-managed GCP Service Account email, an error is returned.
func ProjectIDFromGCPEmail(in string) (string, error) {
err := ValidateGCPServiceAccountEmail(in)
if err != nil {
return "", err
}

spl := strings.Split(in, "@")
if len(spl) != 2 {
return "", errors.New("no @ in email")
}
return strings.TrimSuffix(spl[1], gcpUserManagedServiceAccountEmailSuffix), nil
}

// ValidateGCPKMSKeyResourceID validates whether the given string is potentially a valid resource ID for a GCP KMS key
// The function does a best-effort check. If no error is returned, this does not mean the value is accepted by GCP.
func ValidateGCPKMSKeyResourceID(v string) error {
invalidErr := errors.New("not a valid resource ID, expected: projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/KEY")
u, err := url.Parse(v)
if err != nil {
return invalidErr
return ErrInvalidGCPKMSResourceID
}
if u.Host != "" || u.Scheme != "" || u.Hostname() != "" || len(u.Query()) != 0 {
return invalidErr
return ErrInvalidGCPKMSResourceID
}

split := strings.Split(v, "/")
if len(split) != 8 {
return invalidErr
return ErrInvalidGCPKMSResourceID
}
if split[0] != "projects" || split[2] != "locations" || split[4] != "keyRings" || split[6] != "cryptoKeys" {
return invalidErr
return ErrInvalidGCPKMSResourceID
}

return nil
Expand Down
37 changes: 34 additions & 3 deletions internals/api/patterns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,13 +474,16 @@ func TestValidateGCPServiceAccountEmail(t *testing.T) {
in: "test-service-account@secrethub-test-1234567890.iam.gserviceaccount.com",
},
"appspot service account": {
in: "secrethub-1234567890@appspot.gserviceaccount.com",
in: "secrethub-1234567890@appspot.gserviceaccount.com",
expectErr: true,
},
"compute service account": {
in: "secrethub-1234567890-compute@developer.gserviceaccount.com",
in: "secrethub-1234567890-compute@developer.gserviceaccount.com",
expectErr: true,
},
"google managed service account": {
in: "secrethub-1234567890@cloudservices.gserviceaccount.com",
in: "secrethub-1234567890@cloudservices.gserviceaccount.com",
expectErr: true,
},
"not an email": {
in: "cloudservices.gserviceaccount.com",
Expand All @@ -505,6 +508,34 @@ func TestValidateGCPServiceAccountEmail(t *testing.T) {
}
}

func TestProjectIDFromGCPEmail(t *testing.T) {
cases := map[string]struct {
in string
expectErr bool
expect string
}{
"user managed service account": {
in: "test-service-account@secrethub-test-1234567890.iam.gserviceaccount.com",
expect: "secrethub-test-1234567890",
},
"invalid email": {
in: "secrethub-1234567890-compute@developer.gserviceaccount.com",
expectErr: true,
},
}

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
projectID, err := api.ProjectIDFromGCPEmail(tc.in)

assert.Equal(t, err != nil, tc.expectErr)
if !tc.expectErr {
assert.Equal(t, projectID, tc.expect)
}
})
}
}

func TestValidateGCPKMSKeyResourceID(t *testing.T) {
cases := map[string]struct {
in string
Expand Down
16 changes: 16 additions & 0 deletions internals/api/server_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,19 @@ func IsErrNotFound(err error) bool {
}
return publicStatusError.StatusCode == 404
}

// IsErrDisabled returns whether the given error is caused because the feature is disabled.
func IsErrDisabled(err error) bool {
var publicStatusError errio.PublicStatusError
ok := errors.As(err, &publicStatusError)
if !ok {
return false
}
return publicStatusError.StatusCode == http.StatusNotImplemented
}

// IsKnownError returns whether the given error is a known SecretHub error.
func IsKnownError(err error) bool {
var publicStatusError errio.PublicStatusError
return errors.As(err, &publicStatusError)
}
27 changes: 23 additions & 4 deletions internals/gcp/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@ package gcp

import (
"net/http"
"strings"

"google.golang.org/api/googleapi"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

"github.com/secrethub/secrethub-go/internals/errio"
)

// Errors
var (
gcpErr = errio.Namespace("gcp")
ErrGCPAlreadyExists = gcpErr.Code("already_exists")
ErrGCPNotFound = gcpErr.Code("not_found")
ErrGCPAccessDenied = gcpErr.Code("access_denied")
gcpErr = errio.Namespace("gcp")
ErrGCPAlreadyExists = gcpErr.Code("already_exists")
ErrGCPNotFound = gcpErr.Code("not_found")
ErrGCPAccessDenied = gcpErr.Code("access_denied")
ErrGCPInvalidArgument = gcpErr.Code("invalid_argument")
ErrGCPUnauthenticated = gcpErr.Code("unauthenticated").Error("missing valid GCP authentication")
)

func HandleError(err error) error {
Expand All @@ -31,5 +36,19 @@ func HandleError(err error) error {
return gcpErr.Code(errGCP.Errors[0].Reason).Error(errGCP.Errors[0].Message)
}
}
errStatus, ok := status.FromError(err)
if ok {
msg := strings.TrimSuffix(errStatus.Message(), ".")
switch errStatus.Code() {
case codes.InvalidArgument:
return ErrGCPInvalidArgument.Error(msg)
case codes.NotFound:
return ErrGCPNotFound.Error(msg)
case codes.PermissionDenied:
return ErrGCPAccessDenied.Error(msg)
case codes.Unauthenticated:
return ErrGCPUnauthenticated
}
}
return err
}
7 changes: 7 additions & 0 deletions internals/gcp/service_creator.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ type CredentialCreator struct {
// NewCredentialCreator returns a CredentialCreator that uses the provided GCP KMS key and Service Account Email to create a new credential.
// The GCP client is configured with the optionally provided option.ClientOption.
func NewCredentialCreator(serviceAccountEmail, keyResourceID string, gcpOptions ...option.ClientOption) (*CredentialCreator, map[string]string, error) {
if err := api.ValidateGCPServiceAccountEmail(serviceAccountEmail); err != nil {
return nil, nil, err
}
if err := api.ValidateGCPKMSKeyResourceID(keyResourceID); err != nil {
return nil, nil, err
}

kmsClient, err := kms.NewKeyManagementClient(context.Background(), gcpOptions...)
if err != nil {
return nil, nil, fmt.Errorf("creating kms client: %v", HandleError(err))
Expand Down
66 changes: 66 additions & 0 deletions internals/oauthorizer/authorizer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package oauthorizer

import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)

type Authorizer interface {
AuthorizeLink(redirectURI string, state string) string
ParseResponse(r *http.Request, state string) (string, error)
}

func NewAuthorizer(authURI, clientID string, scopes ...string) Authorizer {
return authorizer{
AuthURI: authURI,
ClientID: clientID,
Scopes: scopes,
}
}

type authorizer struct {
AuthURI string
ClientID string
Scopes []string
}

func (a authorizer) AuthorizeLink(redirectURI string, state string) string {
return fmt.Sprintf(`%s?`+
`scope=%s&`+
`access_type=online&`+
`response_type=code&`+
`redirect_uri=%s&`+
`state=%s&`+
`prompt=select_account&`+
`client_id=%s`,
a.AuthURI,
url.QueryEscape(strings.Join(a.Scopes, ",")),
url.QueryEscape(redirectURI),
state,
a.ClientID,
)
}

func (a authorizer) ParseResponse(r *http.Request, expectedState string) (string, error) {
errorMessage := r.URL.Query().Get("error")
if errorMessage != "" {
return "", fmt.Errorf("authorization error: %s", errorMessage)
}

state := r.URL.Query().Get("state")
if state == "" {
return "", errors.New("missing state query parameter")
}
if state != expectedState {
return "", errors.New("state does not match")
}

code := r.URL.Query().Get("code")
if code == "" {
return "", errors.New("missing code query parameter")
}
return code, nil
}
Loading