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 9 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
68 changes: 68 additions & 0 deletions internals/api/idp_link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package api

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

"github.com/secrethub/secrethub-go/pkg/oauthorizer"
)

var (
ErrInvalidIDPLinkType = errAPI.Code("invalid_idP_link_type").StatusError("invalid IDP link type", http.StatusBadRequest)
SimonBarendse marked this conversation as resolved.
Show resolved Hide resolved
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"
)

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"`
}

func (c OAuthConfig) Authorizer() oauthorizer.Authorizer {
return oauthorizer.NewAuthorizer(c.AuthURI, c.ClientID, c.Scopes...)
}

// 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
}
21 changes: 19 additions & 2 deletions internals/api/patterns.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ 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 = ".iam.gserviceaccount.com"
)

var (
Expand Down Expand Up @@ -301,12 +303,27 @@ func ValidateGCPServiceAccountEmail(v string) error {
if !govalidator.IsEmail(v) {
return errors.New("invalid email")
}
if !strings.HasSuffix(v, ".gserviceaccount.com") {
return errors.New("not a GCP Service Account email")
if !strings.HasSuffix(v, gcpServiceAccountEmailSuffix) {
return errors.New("not a user-managed GCP Service Account email")
}
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], gcpServiceAccountEmailSuffix), 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 {
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
6 changes: 6 additions & 0 deletions internals/api/server_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,9 @@ func IsErrNotFound(err error) bool {
}
return publicStatusError.StatusCode == 404
}

// 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)
}
66 changes: 66 additions & 0 deletions pkg/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
}
75 changes: 75 additions & 0 deletions pkg/oauthorizer/callback_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package oauthorizer

import (
"fmt"
"net"
"net/http"

"github.com/secrethub/secrethub-go/pkg/randchar"
)

type CallbackHandler struct {
authorizer Authorizer
listener net.Listener
state string

resChan chan string
errChan chan error
}

func NewCallbackHandler(authorizer Authorizer) (CallbackHandler, error) {
state, err := randchar.Generate(20)
if err != nil {
return CallbackHandler{}, fmt.Errorf("generating random state: %s", err)
}

l, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
return CallbackHandler{}, err
}

return CallbackHandler{
authorizer: authorizer,
listener: l,
state: string(state),
resChan: make(chan string),
errChan: make(chan error),
}, nil
}

func (s CallbackHandler) ListenURL() string {
return "http://" + s.listener.Addr().String()
}

func (s CallbackHandler) AuthorizeURL() string {
return s.authorizer.AuthorizeLink(s.ListenURL(), s.state)
}

func (s CallbackHandler) WaitForAuthorizationCode() (string, error) {
defer s.listener.Close()

go func() {
err := http.Serve(s.listener, http.HandlerFunc(s.handleRequest))
if err != nil && err != http.ErrServerClosed {
SimonBarendse marked this conversation as resolved.
Show resolved Hide resolved
s.errChan <- err
}
}()

select {
case err := <-s.errChan:
return "", err
case res := <-s.resChan:
return res, nil
}
}

func (s CallbackHandler) handleRequest(w http.ResponseWriter, r *http.Request) {
code, err := s.authorizer.ParseResponse(r, s.state)
if err != nil {
s.errChan <- err
fmt.Fprintf(w, "Error: %s", err)
} else {
s.resChan <- code
fmt.Fprint(w, "Authorization complete. You can now close this tab")
}
}
6 changes: 6 additions & 0 deletions pkg/secrethub/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ type ClientInterface interface {
Credentials() CredentialService
// Dirs returns a service used to manage directories.
Dirs() DirService
// IDPLinks returns a service used to manage links between namespaces and Identity Providers.
IDPLinks() IDPLinkService
// Me returns a service used to manage the current authenticated account.
Me() MeService
// Orgs returns a service used to manage shared organization workspaces.
Expand Down Expand Up @@ -213,6 +215,10 @@ func (c *Client) Dirs() DirService {
return newDirService(c)
}

func (c *Client) IDPLinks() IDPLinkService {
return newIDPLinkService(c)
}

// Me returns a service used to manage the current authenticated account.
func (c *Client) Me() MeService {
return newMeService(c)
Expand Down
Loading