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

Commit 4b34720

Browse files
authored
Merge pull request #203 from secrethub/release/v0.30.0
Release v0.30.0
2 parents 3410150 + 656e71c commit 4b34720

File tree

16 files changed

+600
-37
lines changed

16 files changed

+600
-37
lines changed

Makefile

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ commit: format lint test
33
format:
44
@goimports -w $(find . -type f -name '*.go')
55

6+
GOLANGCI_VERSION=v1.23.8
67
lint:
7-
@golangci-lint run
8+
@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 ./...
89

910
test:
1011
@go test ./...
@@ -14,10 +15,5 @@ tools: format-tools lint-tools
1415
format-tools:
1516
@go get -u golang.org/x/tools/cmd/goimports
1617

17-
GOLANGCI_VERSION=v1.23.8
18-
19-
lint-tools:
20-
@curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_VERSION}
21-
2218
check-version:
2319
./scripts/check-version/check-version.sh

internals/api/credential.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/base64"
77
"encoding/hex"
88
"encoding/json"
9+
"errors"
910
"fmt"
1011
"net/http"
1112
"time"
@@ -84,7 +85,7 @@ var credentialTypesMetadata = map[CredentialType]map[string]func(string) error{
8485
CredentialMetadataAWSKMSKey: nil,
8586
},
8687
CredentialTypeGCPServiceAccount: {
87-
CredentialMetadataGCPServiceAccountEmail: ValidateGCPServiceAccountEmail,
88+
CredentialMetadataGCPServiceAccountEmail: ValidateGCPUserManagedServiceAccountEmail,
8889
CredentialMetadataGCPKMSKeyResourceID: ValidateGCPKMSKeyResourceID,
8990
},
9091
CredentialTypeBackupCode: {},
@@ -224,6 +225,23 @@ func (req *CreateCredentialRequest) Validate() error {
224225
return nil
225226
}
226227

228+
// RequiredIDPLink can be used if the credential requires an IDP Link to exist before creation.
229+
// It returns the link type and the linked ID if a link is required.
230+
// It returns empty strings if no link is required for the credential type.
231+
func (req *CreateCredentialRequest) RequiredIDPLink() (IdentityProviderLinkType, string, error) {
232+
switch req.Type {
233+
case CredentialTypeGCPServiceAccount:
234+
serviceAccountEmail, ok := req.Metadata[CredentialMetadataGCPServiceAccountEmail]
235+
if !ok {
236+
return IdentityProviderLinkGCP, "", errors.New("missing required metadata")
237+
}
238+
projectID, err := ProjectIDFromGCPEmail(serviceAccountEmail)
239+
return IdentityProviderLinkGCP, projectID, err
240+
default:
241+
return "", "", nil
242+
}
243+
}
244+
227245
// CredentialProofAWS is proof for when the credential type is AWSSTS.
228246
type CredentialProofAWS struct {
229247
Region string `json:"region"`

internals/api/idp_link.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
"regexp"
7+
"time"
8+
)
9+
10+
var (
11+
ErrInvalidIDPLinkType = errAPI.Code("invalid_idp_link_type").StatusError("invalid IDP link type", http.StatusBadRequest)
12+
ErrInvalidGCPProjectID = errAPI.Code("invalid_gcp_project_id").StatusErrorPref("invalid GCP project ID: %s", http.StatusBadRequest)
13+
ErrVerifyingGCPAccessProof = errAPI.Code("gcp_verification_error").StatusError("could not verify GCP authorization", http.StatusInternalServerError)
14+
ErrInvalidGCPAuthorizationCode = errAPI.Code("invalid_authorization_code").StatusError("authorization code was not accepted by GCP", http.StatusPreconditionFailed)
15+
ErrGCPLinkPermissionDenied = errAPI.Code("gcp_permission_denied").StatusError("missing required projects.get permission to create link to GCP project", http.StatusPreconditionFailed)
16+
17+
gcpProjectIDPattern = regexp.MustCompile("^[a-z][a-z0-9-]*[a-z0-9]$")
18+
)
19+
20+
type CreateIdentityProviderLinkGCPRequest struct {
21+
RedirectURL string `json:"redirect_url"`
22+
AuthorizationCode string `json:"authorization_code"`
23+
}
24+
25+
type IdentityProviderLinkType string
26+
27+
const (
28+
IdentityProviderLinkGCP IdentityProviderLinkType = "gcp"
29+
)
30+
31+
// IdentityProviderLink is a prerequisite for creating some identity provider backed service accounts.
32+
// These links prove that a namespace's member has access to a resource (identified by the LinkedID) within
33+
// the identity provider. Once a link between a namespace and an identity provider has been created, from then on
34+
// service accounts can be created within the scope described by the LinkedID. For example, after creating a link
35+
// to a GCP Project, GCP service accounts within that project can be used for the GCP Identity Provider.
36+
//
37+
// The meaning of LinkedID depends on the type of the IdentityProviderLink in the following way:
38+
// - GCP: LinkedID is a GCP Project ID.
39+
type IdentityProviderLink struct {
40+
Type IdentityProviderLinkType `json:"type"`
41+
Namespace string `json:"namespace"`
42+
LinkedID string `json:"linked_id"`
43+
CreatedAt time.Time `json:"created_at"`
44+
}
45+
46+
type OAuthConfig struct {
47+
ClientID string `json:"client_id"`
48+
AuthURI string `json:"auth_uri"`
49+
Scopes []string `json:"scopes"`
50+
ResultURL *url.URL `json:"result_url"`
51+
}
52+
53+
// ValidateLinkedID calls the validation function corresponding to the link type and returns the corresponding result.
54+
func ValidateLinkedID(linkType IdentityProviderLinkType, linkedID string) error {
55+
switch linkType {
56+
case IdentityProviderLinkGCP:
57+
return ValidateGCPProjectID(linkedID)
58+
default:
59+
return ErrInvalidIDPLinkType
60+
}
61+
}
62+
63+
// ValidateGCPProjectID returns an error if the provided value is not a valid GCP project ID.
64+
func ValidateGCPProjectID(projectID string) error {
65+
if len(projectID) < 6 || len(projectID) > 30 {
66+
return ErrInvalidGCPProjectID("length must be 6 to 30 character")
67+
}
68+
if !gcpProjectIDPattern.MatchString(projectID) {
69+
return ErrInvalidGCPProjectID("can only contains lowercase letter, digits and hyphens")
70+
}
71+
return nil
72+
}

internals/api/paths.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ var (
1515
ErrInvalidSecretPath = errAPI.Code("invalid_secret_path").ErrorPref("secret path must be of the form <namespace>/<repo>[/<dir-path>]/<secret> got '%s'")
1616
ErrInvalidRepoPath = errAPI.Code("invalid_repo_path").ErrorPref("repo path must be of the form <namespace>/<repo> got '%s'")
1717
ErrInvalidDirPath = errAPI.Code("invalid_dir_path").ErrorPref("dir path must be of the form <namespace>/<repo>[/<dir-path>] got '%s'")
18-
ErrInvalidNamespace = errAPI.Code("invalid_namespace").Error("namespace must be a valid username")
18+
ErrInvalidNamespace = errAPI.Code("invalid_namespace").Error("namespace must be a valid username or organization name")
1919
ErrInvalidPath = errAPI.Code("invalid_path").Error("path is not a reference to a namespace, a repository, a directory, or a secret")
2020
ErrInvalidPathType = errAPI.Code("invalid_path_type").Error("using an unknown path type")
2121
ErrPathAlreadyHasVersion = errAPI.Code("path_already_has_version").Error("this secret path already has a version")

internals/api/patterns.go

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ const (
2424
// REGEX builder with unit tests: https://regex101.com/r/5DPAiZ/1
2525
patternFullName = `[\p{L}\p{Mn}\p{Pd}\'\x{2019} ]`
2626
patternDescription = `^[\p{L}\p{Mn}\p{Pd}\x{2019} [:punct:]0-9]{0,144}$`
27+
28+
gcpServiceAccountEmailSuffix = ".gserviceaccount.com"
29+
gcpUserManagedServiceAccountEmailSuffix = ".iam.gserviceaccount.com"
2730
)
2831

2932
var (
@@ -82,6 +85,10 @@ var (
8285
"credential fingerprint must consist of 64 hexadecimal characters",
8386
http.StatusBadRequest,
8487
)
88+
89+
ErrInvalidGCPServiceAccountEmail = errAPI.Code("invalid_service_account_email").StatusError("not a valid GCP service account email", http.StatusBadRequest)
90+
ErrNotUserManagerGCPServiceAccountEmail = errAPI.Code("require_user_managed_service_account").StatusError("provided GCP service account email is not for a user-manager service account", http.StatusBadRequest)
91+
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)
8592
)
8693

8794
// ValidateNamespace validates a username.
@@ -294,37 +301,54 @@ func ValidateShortCredentialFingerprint(fingerprint string) error {
294301
return nil
295302
}
296303

297-
// ValidateGCPServiceAccountEmail validates whether the given string is potentially a valid email for a GCP
298-
// Service Account. The function does a best-effort check. If no error is returned, this does not mean the value is
299-
// accepted by GCP.
300-
func ValidateGCPServiceAccountEmail(v string) error {
304+
// ValidateGCPUserManagedServiceAccountEmail validates whether the given string is potentially a valid email for a
305+
// user-managed GCP Service Account. The function does a best-effort check. If no error is returned, this does not mean
306+
// the value is accepted by GCP.
307+
func ValidateGCPUserManagedServiceAccountEmail(v string) error {
301308
if !govalidator.IsEmail(v) {
302-
return errors.New("invalid email")
309+
return ErrInvalidGCPServiceAccountEmail
310+
}
311+
if !strings.HasSuffix(v, gcpServiceAccountEmailSuffix) {
312+
return ErrInvalidGCPServiceAccountEmail
303313
}
304-
if !strings.HasSuffix(v, ".gserviceaccount.com") {
305-
return errors.New("not a GCP Service Account email")
314+
if !strings.HasSuffix(v, gcpUserManagedServiceAccountEmailSuffix) {
315+
return ErrNotUserManagerGCPServiceAccountEmail
306316
}
307317
return nil
308318
}
309319

320+
// ProjectIDFromGCPEmail returns the project ID included in the email of a GCP Service Account.
321+
// If the input is not a valid user-managed GCP Service Account email, an error is returned.
322+
func ProjectIDFromGCPEmail(in string) (string, error) {
323+
err := ValidateGCPUserManagedServiceAccountEmail(in)
324+
if err != nil {
325+
return "", err
326+
}
327+
328+
spl := strings.Split(in, "@")
329+
if len(spl) != 2 {
330+
return "", errors.New("no @ in email")
331+
}
332+
return strings.TrimSuffix(spl[1], gcpUserManagedServiceAccountEmailSuffix), nil
333+
}
334+
310335
// ValidateGCPKMSKeyResourceID validates whether the given string is potentially a valid resource ID for a GCP KMS key
311336
// The function does a best-effort check. If no error is returned, this does not mean the value is accepted by GCP.
312337
func ValidateGCPKMSKeyResourceID(v string) error {
313-
invalidErr := errors.New("not a valid resource ID, expected: projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/KEY")
314338
u, err := url.Parse(v)
315339
if err != nil {
316-
return invalidErr
340+
return ErrInvalidGCPKMSResourceID
317341
}
318342
if u.Host != "" || u.Scheme != "" || u.Hostname() != "" || len(u.Query()) != 0 {
319-
return invalidErr
343+
return ErrInvalidGCPKMSResourceID
320344
}
321345

322346
split := strings.Split(v, "/")
323347
if len(split) != 8 {
324-
return invalidErr
348+
return ErrInvalidGCPKMSResourceID
325349
}
326350
if split[0] != "projects" || split[2] != "locations" || split[4] != "keyRings" || split[6] != "cryptoKeys" {
327-
return invalidErr
351+
return ErrInvalidGCPKMSResourceID
328352
}
329353

330354
return nil

internals/api/patterns_test.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -474,13 +474,16 @@ func TestValidateGCPServiceAccountEmail(t *testing.T) {
474474
in: "test-service-account@secrethub-test-1234567890.iam.gserviceaccount.com",
475475
},
476476
"appspot service account": {
477-
477+
478+
expectErr: true,
478479
},
479480
"compute service account": {
480-
481+
482+
expectErr: true,
481483
},
482484
"google managed service account": {
483-
485+
486+
expectErr: true,
484487
},
485488
"not an email": {
486489
in: "cloudservices.gserviceaccount.com",
@@ -498,9 +501,37 @@ func TestValidateGCPServiceAccountEmail(t *testing.T) {
498501

499502
for name, tc := range cases {
500503
t.Run(name, func(t *testing.T) {
501-
err := api.ValidateGCPServiceAccountEmail(tc.in)
504+
err := api.ValidateGCPUserManagedServiceAccountEmail(tc.in)
505+
506+
assert.Equal(t, err != nil, tc.expectErr)
507+
})
508+
}
509+
}
510+
511+
func TestProjectIDFromGCPEmail(t *testing.T) {
512+
cases := map[string]struct {
513+
in string
514+
expectErr bool
515+
expect string
516+
}{
517+
"user managed service account": {
518+
in: "test-service-account@secrethub-test-1234567890.iam.gserviceaccount.com",
519+
expect: "secrethub-test-1234567890",
520+
},
521+
"invalid email": {
522+
523+
expectErr: true,
524+
},
525+
}
526+
527+
for name, tc := range cases {
528+
t.Run(name, func(t *testing.T) {
529+
projectID, err := api.ProjectIDFromGCPEmail(tc.in)
502530

503531
assert.Equal(t, err != nil, tc.expectErr)
532+
if !tc.expectErr {
533+
assert.Equal(t, projectID, tc.expect)
534+
}
504535
})
505536
}
506537
}

internals/api/server_errors.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ var (
8484
ErrNoAdminAccess = errHub.Code("no_admin_access").StatusError("Only accounts with Admin access can perform this action", http.StatusForbidden)
8585
ErrMemberAlreadyExists = errHub.Code("member_already_exists").StatusError("The member already exists", http.StatusConflict)
8686

87+
// AWS IdP
88+
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)
89+
90+
// GCP IdP
91+
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)
92+
8793
// Account
8894
ErrAccountNotFound = errHub.Code("account_not_found").StatusError("Account not found", http.StatusNotFound)
8995
ErrUnknownSubjectType = errHub.Code("unknown_subject_type").Error("Unknown subject type") // no status error because it is an internal error
@@ -112,3 +118,19 @@ func IsErrNotFound(err error) bool {
112118
}
113119
return publicStatusError.StatusCode == 404
114120
}
121+
122+
// IsErrDisabled returns whether the given error is caused because the feature is disabled.
123+
func IsErrDisabled(err error) bool {
124+
var publicStatusError errio.PublicStatusError
125+
ok := errors.As(err, &publicStatusError)
126+
if !ok {
127+
return false
128+
}
129+
return publicStatusError.StatusCode == http.StatusNotImplemented
130+
}
131+
132+
// IsKnownError returns whether the given error is a known SecretHub error.
133+
func IsKnownError(err error) bool {
134+
var publicStatusError errio.PublicStatusError
135+
return errors.As(err, &publicStatusError)
136+
}

internals/gcp/errors.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,63 @@ package gcp
22

33
import (
44
"net/http"
5+
"strings"
56

67
"google.golang.org/api/googleapi"
8+
"google.golang.org/grpc/codes"
9+
"google.golang.org/grpc/status"
710

811
"github.com/secrethub/secrethub-go/internals/errio"
912
)
1013

1114
// Errors
1215
var (
13-
gcpErr = errio.Namespace("gcp")
14-
ErrGCPAlreadyExists = gcpErr.Code("already_exists")
15-
ErrGCPNotFound = gcpErr.Code("not_found")
16-
ErrGCPAccessDenied = gcpErr.Code("access_denied")
16+
gcpErr = errio.Namespace("gcp")
17+
ErrGCPAlreadyExists = gcpErr.Code("already_exists")
18+
ErrGCPNotFound = gcpErr.Code("not_found")
19+
ErrGCPAccessDenied = gcpErr.Code("access_denied")
20+
ErrGCPInvalidArgument = gcpErr.Code("invalid_argument")
21+
ErrGCPUnauthenticated = gcpErr.Code("unauthenticated").Error("missing valid GCP authentication")
1722
)
1823

1924
func HandleError(err error) error {
2025
errGCP, ok := err.(*googleapi.Error)
2126
if ok {
27+
message := errGCP.Message
2228
switch errGCP.Code {
2329
case http.StatusNotFound:
24-
return ErrGCPNotFound.Error(errGCP.Message)
30+
if message == "" {
31+
message = "Response from the Google API: 404 Not Found"
32+
}
33+
return ErrGCPNotFound.Error(message)
2534
case http.StatusForbidden:
26-
return ErrGCPAccessDenied.Error(errGCP.Message)
35+
if message == "" {
36+
message = "Response from the Google API: 403 Forbidden"
37+
}
38+
return ErrGCPAccessDenied.Error(message)
2739
case http.StatusConflict:
28-
return ErrGCPAlreadyExists.Error(errGCP.Message)
40+
if message == "" {
41+
message = "Response from the Google API: 409 Conflict"
42+
}
43+
return ErrGCPAlreadyExists.Error(message)
2944
}
3045
if len(errGCP.Errors) > 0 {
3146
return gcpErr.Code(errGCP.Errors[0].Reason).Error(errGCP.Errors[0].Message)
3247
}
3348
}
49+
errStatus, ok := status.FromError(err)
50+
if ok {
51+
msg := strings.TrimSuffix(errStatus.Message(), ".")
52+
switch errStatus.Code() {
53+
case codes.InvalidArgument:
54+
return ErrGCPInvalidArgument.Error(msg)
55+
case codes.NotFound:
56+
return ErrGCPNotFound.Error(msg)
57+
case codes.PermissionDenied:
58+
return ErrGCPAccessDenied.Error(msg)
59+
case codes.Unauthenticated:
60+
return ErrGCPUnauthenticated
61+
}
62+
}
3463
return err
3564
}

0 commit comments

Comments
 (0)