diff --git a/Makefile b/Makefile index 45a9ead6..a76e00a8 100644 --- a/Makefile +++ b/Makefile @@ -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 ./... @@ -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 diff --git a/internals/api/credential.go b/internals/api/credential.go index 4bda06ea..96419658 100644 --- a/internals/api/credential.go +++ b/internals/api/credential.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "net/http" "time" @@ -84,7 +85,7 @@ var credentialTypesMetadata = map[CredentialType]map[string]func(string) error{ CredentialMetadataAWSKMSKey: nil, }, CredentialTypeGCPServiceAccount: { - CredentialMetadataGCPServiceAccountEmail: ValidateGCPServiceAccountEmail, + CredentialMetadataGCPServiceAccountEmail: ValidateGCPUserManagedServiceAccountEmail, CredentialMetadataGCPKMSKeyResourceID: ValidateGCPKMSKeyResourceID, }, CredentialTypeBackupCode: {}, @@ -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"` diff --git a/internals/api/idp_link.go b/internals/api/idp_link.go new file mode 100644 index 00000000..abe9aa2e --- /dev/null +++ b/internals/api/idp_link.go @@ -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 +} diff --git a/internals/api/paths.go b/internals/api/paths.go index 80727015..3374358e 100644 --- a/internals/api/paths.go +++ b/internals/api/paths.go @@ -15,7 +15,7 @@ var ( ErrInvalidSecretPath = errAPI.Code("invalid_secret_path").ErrorPref("secret path must be of the form /[/]/ got '%s'") ErrInvalidRepoPath = errAPI.Code("invalid_repo_path").ErrorPref("repo path must be of the form / got '%s'") ErrInvalidDirPath = errAPI.Code("invalid_dir_path").ErrorPref("dir path must be of the form /[/] 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") diff --git a/internals/api/patterns.go b/internals/api/patterns.go index 98b8fc83..e9d9e1e0 100644 --- a/internals/api/patterns.go +++ b/internals/api/patterns.go @@ -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 ( @@ -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. @@ -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 diff --git a/internals/api/patterns_test.go b/internals/api/patterns_test.go index 3eaffe2c..41d6cfbb 100644 --- a/internals/api/patterns_test.go +++ b/internals/api/patterns_test.go @@ -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", @@ -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: "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) + } }) } } diff --git a/internals/api/server_errors.go b/internals/api/server_errors.go index 49904fbb..aca1ff7c 100644 --- a/internals/api/server_errors.go +++ b/internals/api/server_errors.go @@ -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 @@ -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) +} diff --git a/internals/gcp/errors.go b/internals/gcp/errors.go index b1db0fe1..dd4ccecb 100644 --- a/internals/gcp/errors.go +++ b/internals/gcp/errors.go @@ -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 } diff --git a/internals/gcp/service_creator.go b/internals/gcp/service_creator.go index 1c860ff7..1f4b88c2 100644 --- a/internals/gcp/service_creator.go +++ b/internals/gcp/service_creator.go @@ -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.ValidateGCPUserManagedServiceAccountEmail(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)) diff --git a/internals/oauthorizer/authorizer.go b/internals/oauthorizer/authorizer.go new file mode 100644 index 00000000..7b754d36 --- /dev/null +++ b/internals/oauthorizer/authorizer.go @@ -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 +} diff --git a/internals/oauthorizer/callback_handler.go b/internals/oauthorizer/callback_handler.go new file mode 100644 index 00000000..35b75f07 --- /dev/null +++ b/internals/oauthorizer/callback_handler.go @@ -0,0 +1,106 @@ +package oauthorizer + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "sync" + + "github.com/secrethub/secrethub-go/pkg/randchar" +) + +type CallbackHandler struct { + authorizer Authorizer + listener net.Listener + state string + + baseRedirectURL *url.URL + + errChan chan error +} + +func NewCallbackHandler(redirectURL *url.URL, 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, + baseRedirectURL: redirectURL, + listener: l, + state: string(state), + errChan: make(chan error, 1), + }, 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) +} + +// WithAuthorizationCode executes the provided function with the resulting authorization code or error. +// Afterwards the user is redirected to the CallbackHandler's baseRedirectURL. If the callback produced an error, +// the error is appended to the redirect url: &error=. +// The provided callback function will only be executed once, even if multiple successful callbacks arrive at the server. +// This function returns when the callback has been executed and the user is redirected. +func (s CallbackHandler) WithAuthorizationCode(callback func(string) error) error { + defer s.listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + s.errChan <- http.Serve(s.listener, http.HandlerFunc(s.handleRequest(callback, cancel))) + cancel() + }() + + <-ctx.Done() + + select { + case err := <-s.errChan: + return err + default: + return nil + } +} + +func (s CallbackHandler) handleRequest(callback func(string) error, done func()) func(w http.ResponseWriter, r *http.Request) { + var once sync.Once + var redirectURL *url.URL + return func(w http.ResponseWriter, r *http.Request) { + once.Do(func() { + redirectURL = s.baseRedirectURL + err := func() error { + code, err := s.authorizer.ParseResponse(r, s.state) + if err != nil { + return err + } + + err = callback(code) + if err != nil { + return err + } + return nil + }() + if err != nil { + q := redirectURL.Query() + q.Set("error", err.Error()) + redirectURL.RawQuery = q.Encode() + s.errChan <- err + } + }) + + http.Redirect(w, r, redirectURL.String(), http.StatusSeeOther) + done() + } +} diff --git a/pkg/secrethub/client.go b/pkg/secrethub/client.go index d1186b75..8f752c7e 100644 --- a/pkg/secrethub/client.go +++ b/pkg/secrethub/client.go @@ -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. @@ -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) diff --git a/pkg/secrethub/client_version.go b/pkg/secrethub/client_version.go index fd80be5c..d2a567cb 100644 --- a/pkg/secrethub/client_version.go +++ b/pkg/secrethub/client_version.go @@ -2,4 +2,4 @@ package secrethub // ClientVersion is the current version of the client // Do not edit this unless you know what you're doing. -const ClientVersion = "v0.29.0" +const ClientVersion = "v0.30.0" diff --git a/pkg/secrethub/credentials/gcp.go b/pkg/secrethub/credentials/gcp.go index c6a2b523..7228a91a 100644 --- a/pkg/secrethub/credentials/gcp.go +++ b/pkg/secrethub/credentials/gcp.go @@ -14,11 +14,11 @@ import ( // If used on GCP (e.g. from a Compute Engine instance), this extra configuration is not required and the correct // configuration should be auto-detected by the GCP client. // -// Note: this functionality currently is in private beta. It will only work on selected namespaces. +// Access to the GCP metadata server is required for this function to work. In practice, this means that it can +// only be run on GCP. // // Usage: // credentials.UseGCPServiceAccount() -// credentials.UseGCPServiceAccount(option.WithAPIKey("a-custom-api-key")) func UseGCPServiceAccount(gcpOptions ...option.ClientOption) Provider { return providerFunc(func(httpClient *http.Client) (auth.Authenticator, Decrypter, error) { decrypter, err := gcp.NewKMSDecrypter(gcpOptions...) @@ -35,9 +35,7 @@ func UseGCPServiceAccount(gcpOptions ...option.ClientOption) Provider { // The kmsResourceID is the Resource ID of the key in KMS that is used to encrypt the account key. // The service account should have decryption permission on the provided KMS key. // gcpOptions can be used to optionally configure the used GCP client. For example to set a custom API key. -// The KMS key id and service account emaail are returned in the credentials metadata. -// -// Note: this functionality currently is in private beta. It will only work on selected namespaces. +// The KMS key id and service account email are returned in the credentials metadata. func CreateGCPServiceAccount(serviceAccountEmail string, keyResourceID string, gcpOptions ...option.ClientOption) Creator { return &gcpServiceAccountCreator{ keyResourceID: keyResourceID, diff --git a/pkg/secrethub/idp_link.go b/pkg/secrethub/idp_link.go new file mode 100644 index 00000000..6b4292ff --- /dev/null +++ b/pkg/secrethub/idp_link.go @@ -0,0 +1,144 @@ +package secrethub + +import ( + "github.com/secrethub/secrethub-go/internals/api" + "github.com/secrethub/secrethub-go/internals/oauthorizer" + "github.com/secrethub/secrethub-go/pkg/secrethub/iterator" +) + +type IDPLinkService interface { + GCP() IDPLinkGCPService +} + +func newIDPLinkService(client *Client) IDPLinkService { + return idpLinkService{ + client: client, + } +} + +type idpLinkService struct { + client *Client +} + +func (i idpLinkService) GCP() IDPLinkGCPService { + return newIDPLinkGCPService(i.client) +} + +type IDPLinkGCPService interface { + Create(namespace string, projectID string, authorizationCode string, redirectURI string) (*api.IdentityProviderLink, error) + List(namespace string, params *IdpLinkIteratorParams) IdpLinkIterator + Get(namespace string, projectID string) (*api.IdentityProviderLink, error) + Exists(namespace string, projectID string) (bool, error) + Delete(namespace string, projectID string) error + AuthorizationCodeListener(namespace string, projectID string) (oauthorizer.CallbackHandler, error) +} + +func newIDPLinkGCPService(client *Client) IDPLinkGCPService { + return idpLinkGCPService{ + client: client, + } +} + +type idpLinkGCPService struct { + client *Client +} + +func (i idpLinkGCPService) Create(namespace string, projectID string, authorizationCode, redirectURI string) (*api.IdentityProviderLink, error) { + return i.client.httpClient.CreateIDPLink(namespace, api.IdentityProviderLinkGCP, projectID, &api.CreateIdentityProviderLinkGCPRequest{ + AuthorizationCode: authorizationCode, + RedirectURL: redirectURI, + }) +} + +// List returns an iterator that retrieves all GCP identity provider links for the given namespace. +// +// Usage: +// iter := client.IDPLinks().GCP().List(namespace, &secrethub.IdpLinkIteratorParams{}) +// for { +// idpLink, err := iter.Next() +// if err == iterator.Done { +// break +// } else if err != nil { +// // Handle error +// } +// +// // Use identity provider link +// } +func (i idpLinkGCPService) List(namespace string, params *IdpLinkIteratorParams) IdpLinkIterator { + return &idpLinkIterator{ + iterator: iterator.New( + iterator.PaginatorFactory( + func() ([]interface{}, error) { + idpLinks, err := i.client.httpClient.ListIDPLinks(namespace, api.IdentityProviderLinkGCP) + if err != nil { + return nil, err + } + + res := make([]interface{}, len(idpLinks)) + for i, idpLink := range idpLinks { + res[i] = idpLink + } + return res, nil + }, + ), + ), + } +} + +func (i idpLinkGCPService) Get(namespace string, projectID string) (*api.IdentityProviderLink, error) { + return i.client.httpClient.GetIDPLink(namespace, api.IdentityProviderLinkGCP, projectID) +} + +func (i idpLinkGCPService) Exists(namespace string, projectID string) (bool, error) { + _, err := i.Get(namespace, projectID) + if api.IsErrDisabled(err) { + return true, nil + } else if api.IsErrNotFound(err) { + return false, nil + } else if err != nil { + return false, err + } + return true, nil +} + +func (i idpLinkGCPService) Delete(namespace string, projectID string) error { + return i.client.httpClient.DeleteIDPLink(namespace, api.IdentityProviderLinkGCP, projectID) +} + +func (i idpLinkGCPService) AuthorizationCodeListener(namespace string, projectID string) (oauthorizer.CallbackHandler, error) { + oauthConfig, err := i.client.httpClient.GetGCPOAuthConfig() + if err != nil { + return oauthorizer.CallbackHandler{}, err + } + + redirectURL := oauthConfig.ResultURL + q := redirectURL.Query() + q.Set("namespace", namespace) + q.Set("entity", projectID) + redirectURL.RawQuery = q.Encode() + + authorizer := oauthorizer.NewAuthorizer(oauthConfig.AuthURI, oauthConfig.ClientID, oauthConfig.Scopes...) + return oauthorizer.NewCallbackHandler(redirectURL, authorizer) +} + +// IdpLinkIteratorParams defines parameters used when listing identity provider links. +type IdpLinkIteratorParams struct{} + +// IdpLinkIterator iterates over identity provider links. +type IdpLinkIterator interface { + Next() (api.IdentityProviderLink, error) +} + +type idpLinkIterator struct { + iterator iterator.Iterator +} + +// Next returns the next identity provider link or iterator.Done as an error if all of them have been returned. +func (it *idpLinkIterator) Next() (api.IdentityProviderLink, error) { + item, err := it.iterator.Next() + if err != nil { + return api.IdentityProviderLink{}, err + } + + return *item.(*api.IdentityProviderLink), nil +} diff --git a/pkg/secrethub/internals/http/client.go b/pkg/secrethub/internals/http/client.go index 328ed41f..8e82daef 100644 --- a/pkg/secrethub/internals/http/client.go +++ b/pkg/secrethub/internals/http/client.go @@ -84,6 +84,11 @@ const ( pathOrg = "%s/orgs/%s" pathOrgMembers = "%s/orgs/%s/members" pathOrgMember = "%s/orgs/%s/members/%s" + + // Identity Providers + pathGCPOAuthConfig = "%s/identity-providers/gcp/config/oauth2" + pathIDPLinks = "%s/namespaces/%s/identity-providers/%s/links" + pathIDPLink = "%s/namespaces/%s/identity-providers/%s/links/%s" ) const ( @@ -406,6 +411,45 @@ func (c *Client) ListServices(namespace, repoName string) ([]*api.Service, error return out, errio.Error(err) } +// CreateIDPLink creates a new IDP link for a namespace. +func (c *Client) CreateIDPLink(namespace string, t api.IdentityProviderLinkType, linkedID string, in *api.CreateIdentityProviderLinkGCPRequest) (*api.IdentityProviderLink, error) { + out := &api.IdentityProviderLink{} + rawURL := fmt.Sprintf(pathIDPLink, c.base.String(), namespace, t, linkedID) + err := c.put(rawURL, true, http.StatusCreated, in, out) + return out, err +} + +// GetIDPLink return the link identified by namespace, type and linkedID.. +func (c *Client) GetIDPLink(namespace string, t api.IdentityProviderLinkType, linkedID string) (*api.IdentityProviderLink, error) { + out := &api.IdentityProviderLink{} + rawURL := fmt.Sprintf(pathIDPLink, c.base.String(), namespace, t, linkedID) + err := c.get(rawURL, true, &out) + return out, err +} + +// ListIDPLinks lists all IDP links for a namespace and a given type. +func (c *Client) ListIDPLinks(namespace string, t api.IdentityProviderLinkType) ([]*api.IdentityProviderLink, error) { + out := []*api.IdentityProviderLink{} + rawURL := fmt.Sprintf(pathIDPLinks, c.base.String(), namespace, t) + err := c.get(rawURL, true, &out) + return out, err +} + +// DeleteIDPLink deletes an existing IDP link for a namespace. +func (c *Client) DeleteIDPLink(namespace string, t api.IdentityProviderLinkType, linkedID string) error { + rawURL := fmt.Sprintf(pathIDPLink, c.base.String(), namespace, t, linkedID) + err := c.delete(rawURL, true, nil) + return err +} + +// GetGCPOAuthConfig returns the client configuration for using OAuth with GCP. +func (c *Client) GetGCPOAuthConfig() (*api.OAuthConfig, error) { + out := &api.OAuthConfig{} + rawURL := fmt.Sprintf(pathGCPOAuthConfig, c.base.String()) + err := c.get(rawURL, true, out) + return out, err +} + // DIRS // CreateDir creates a new directory in the repo.