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

Commit

Permalink
Merge pull request #203 from secrethub/release/v0.30.0
Browse files Browse the repository at this point in the history
Release v0.30.0
  • Loading branch information
jpcoenen authored Jul 8, 2020
2 parents 3410150 + 656e71c commit 4b34720
Show file tree
Hide file tree
Showing 16 changed files with 600 additions and 37 deletions.
8 changes: 2 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ commit: format lint test
format:
@goimports -w $(find . -type f -name '*.go')

GOLANGCI_VERSION=v1.23.8
lint:
@golangci-lint run
@docker run --rm -t --user $$(id -u):$$(id -g) -v $$(go env GOCACHE):/cache/go -e GOCACHE=/cache/go -e GOLANGCI_LINT_CACHE=/cache/go -v $$(go env GOPATH)/pkg:/go/pkg -v ${PWD}:/app -w /app golangci/golangci-lint:${GOLANGCI_VERSION}-alpine golangci-lint run ./...

test:
@go test ./...
Expand All @@ -14,10 +15,5 @@ tools: format-tools lint-tools
format-tools:
@go get -u golang.org/x/tools/cmd/goimports

GOLANGCI_VERSION=v1.23.8

lint-tools:
@curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_VERSION}

check-version:
./scripts/check-version/check-version.sh
20 changes: 19 additions & 1 deletion 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 @@ -84,7 +85,7 @@ var credentialTypesMetadata = map[CredentialType]map[string]func(string) error{
CredentialMetadataAWSKMSKey: nil,
},
CredentialTypeGCPServiceAccount: {
CredentialMetadataGCPServiceAccountEmail: ValidateGCPServiceAccountEmail,
CredentialMetadataGCPServiceAccountEmail: ValidateGCPUserManagedServiceAccountEmail,
CredentialMetadataGCPKMSKeyResourceID: ValidateGCPKMSKeyResourceID,
},
CredentialTypeBackupCode: {},
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 {
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
}
2 changes: 1 addition & 1 deletion internals/api/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ var (
ErrInvalidSecretPath = errAPI.Code("invalid_secret_path").ErrorPref("secret path must be of the form <namespace>/<repo>[/<dir-path>]/<secret> got '%s'")
ErrInvalidRepoPath = errAPI.Code("invalid_repo_path").ErrorPref("repo path must be of the form <namespace>/<repo> got '%s'")
ErrInvalidDirPath = errAPI.Code("invalid_dir_path").ErrorPref("dir path must be of the form <namespace>/<repo>[/<dir-path>] got '%s'")
ErrInvalidNamespace = errAPI.Code("invalid_namespace").Error("namespace must be a valid username")
ErrInvalidNamespace = errAPI.Code("invalid_namespace").Error("namespace must be a valid username or organization name")
ErrInvalidPath = errAPI.Code("invalid_path").Error("path is not a reference to a namespace, a repository, a directory, or a secret")
ErrInvalidPathType = errAPI.Code("invalid_path_type").Error("using an unknown path type")
ErrPathAlreadyHasVersion = errAPI.Code("path_already_has_version").Error("this secret path already has a version")
Expand Down
48 changes: 36 additions & 12 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 @@ -294,37 +301,54 @@ func ValidateShortCredentialFingerprint(fingerprint string) error {
return nil
}

// ValidateGCPServiceAccountEmail validates whether the given string is potentially a valid email for a GCP
// Service Account. The function does a best-effort check. If no error is returned, this does not mean the value is
// accepted by GCP.
func ValidateGCPServiceAccountEmail(v string) error {
// ValidateGCPUserManagedServiceAccountEmail validates whether the given string is potentially a valid email for a
// user-managed GCP Service Account. The function does a best-effort check. If no error is returned, this does not mean
// the value is accepted by GCP.
func ValidateGCPUserManagedServiceAccountEmail(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 := ValidateGCPUserManagedServiceAccountEmail(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
39 changes: 35 additions & 4 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: "[email protected]",
in: "[email protected]",
expectErr: true,
},
"compute service account": {
in: "[email protected]",
in: "[email protected]",
expectErr: true,
},
"google managed service account": {
in: "[email protected]",
in: "[email protected]",
expectErr: true,
},
"not an email": {
in: "cloudservices.gserviceaccount.com",
Expand All @@ -498,9 +501,37 @@ func TestValidateGCPServiceAccountEmail(t *testing.T) {

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

assert.Equal(t, err != nil, tc.expectErr)
})
}
}

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: "[email protected]",
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)
}
})
}
}
Expand Down
22 changes: 22 additions & 0 deletions internals/api/server_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ var (
ErrNoAdminAccess = errHub.Code("no_admin_access").StatusError("Only accounts with Admin access can perform this action", http.StatusForbidden)
ErrMemberAlreadyExists = errHub.Code("member_already_exists").StatusError("The member already exists", http.StatusConflict)

// AWS IdP
ErrAWSRoleAlreadyTaken = errHub.Code("aws_role_taken").StatusError("a service account coupled to that IAM role already exists. Delete the existing service account or create a new one using a different IAM role.", http.StatusConflict)

// GCP IdP
ErrGCPServiceAccountAlreadyTaken = errHub.Code("gcp_service_account_taken").StatusError("a SecretHub service account coupled to that GCP Service Account email already exists. Delete the existing SecretHub service account or create a new one using a different GCP Service Account email.", http.StatusConflict)

// Account
ErrAccountNotFound = errHub.Code("account_not_found").StatusError("Account not found", http.StatusNotFound)
ErrUnknownSubjectType = errHub.Code("unknown_subject_type").Error("Unknown subject type") // no status error because it is an internal error
Expand Down Expand Up @@ -112,3 +118,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)
}
43 changes: 36 additions & 7 deletions internals/gcp/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,63 @@ 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 {
errGCP, ok := err.(*googleapi.Error)
if ok {
message := errGCP.Message
switch errGCP.Code {
case http.StatusNotFound:
return ErrGCPNotFound.Error(errGCP.Message)
if message == "" {
message = "Response from the Google API: 404 Not Found"
}
return ErrGCPNotFound.Error(message)
case http.StatusForbidden:
return ErrGCPAccessDenied.Error(errGCP.Message)
if message == "" {
message = "Response from the Google API: 403 Forbidden"
}
return ErrGCPAccessDenied.Error(message)
case http.StatusConflict:
return ErrGCPAlreadyExists.Error(errGCP.Message)
if message == "" {
message = "Response from the Google API: 409 Conflict"
}
return ErrGCPAlreadyExists.Error(message)
}
if len(errGCP.Errors) > 0 {
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
}
Loading

0 comments on commit 4b34720

Please sign in to comment.