Add gcp secrets

This commit is contained in:
Jeff Mitchell
2018-03-21 23:07:16 -04:00
parent 8be8f94396
commit 3c51d97ee9
23 changed files with 5134 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ import (
"os/signal"
"syscall"
gcp "github.com/hashicorp/vault-plugin-secrets-gcp/plugin"
kv "github.com/hashicorp/vault-plugin-secrets-kv"
"github.com/hashicorp/vault/audit"
"github.com/hashicorp/vault/logical"
@@ -113,6 +114,7 @@ var (
"cassandra": cassandra.Factory,
"consul": consul.Factory,
"database": database.Factory,
"gcp": gcp.Factory,
"kv": kv.Factory,
"mongodb": mongodb.Factory,
"mssql": mssql.Factory,

363
vendor/github.com/hashicorp/go-gcp-common/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,363 @@
Mozilla Public License, version 2.0
1. Definitions
1.1. "Contributor"
means each individual or legal entity that creates, contributes to the
creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used by a
Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached the
notice in Exhibit A, the Executable Form of such Source Code Form, and
Modifications of such Source Code Form, in each case including portions
thereof.
1.5. "Incompatible With Secondary Licenses"
means
a. that the initial Contributor has attached the notice described in
Exhibit B to the Covered Software; or
b. that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the terms of
a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in a
separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible, whether
at the time of the initial grant or subsequently, any and all of the
rights conveyed by this License.
1.10. "Modifications"
means any of the following:
a. any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered Software; or
b. any new file in Source Code Form that contains any Covered Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the License,
by the making, using, selling, offering for sale, having made, import,
or transfer of either its Contributions or its Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU Lesser
General Public License, Version 2.1, the GNU Affero General Public
License, Version 3.0, or any later versions of those licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that controls, is
controlled by, or is under common control with You. For purposes of this
definition, "control" means (a) the power, direct or indirect, to cause
the direction or management of such entity, whether by contract or
otherwise, or (b) ownership of more than fifty percent (50%) of the
outstanding shares or beneficial ownership of such entity.
2. License Grants and Conditions
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
a. under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
b. under Patent Claims of such Contributor to make, use, sell, offer for
sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
a. for any code that a Contributor has removed from Covered Software; or
b. for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
c. under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights to
grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
Section 2.1.
3. Responsibilities
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
a. such Covered Software must also be made available in Source Code Form,
as described in Section 3.1, and You must inform recipients of the
Executable Form how they can obtain a copy of such Source Code Form by
reasonable means in a timely manner, at a charge no more than the cost
of distribution to the recipient; and
b. You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter the
recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty, or
limitations of liability) contained within the Source Code Form of the
Covered Software, except that You may alter any license notices to the
extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
If it is impossible for You to comply with any of the terms of this License
with respect to some or all of the Covered Software due to statute,
judicial order, or regulation then You must: (a) comply with the terms of
this License to the maximum extent possible; and (b) describe the
limitations and the code they affect. Such description must be placed in a
text file included with all distributions of the Covered Software under
this License. Except to the extent prohibited by statute or regulation,
such description must be sufficiently detailed for a recipient of ordinary
skill to be able to understand it.
5. Termination
5.1. The rights granted under this License will terminate automatically if You
fail to comply with any of its terms. However, if You become compliant,
then the rights granted under this License from a particular Contributor
are reinstated (a) provisionally, unless and until such Contributor
explicitly and finally terminates Your grants, and (b) on an ongoing
basis, if such Contributor fails to notify You of the non-compliance by
some reasonable means prior to 60 days after You have come back into
compliance. Moreover, Your grants from a particular Contributor are
reinstated on an ongoing basis if such Contributor notifies You of the
non-compliance by some reasonable means, this is the first time You have
received notice of non-compliance with this License from such
Contributor, and You become compliant prior to 30 days after Your receipt
of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user
license agreements (excluding distributors and resellers) which have been
validly granted by You or Your distributors under this License prior to
termination shall survive termination.
6. Disclaimer of Warranty
Covered Software is provided under this License on an "as is" basis,
without warranty of any kind, either expressed, implied, or statutory,
including, without limitation, warranties that the Covered Software is free
of defects, merchantable, fit for a particular purpose or non-infringing.
The entire risk as to the quality and performance of the Covered Software
is with You. Should any Covered Software prove defective in any respect,
You (not any Contributor) assume the cost of any necessary servicing,
repair, or correction. This disclaimer of warranty constitutes an essential
part of this License. No use of any Covered Software is authorized under
this License except under this disclaimer.
7. Limitation of Liability
Under no circumstances and under no legal theory, whether tort (including
negligence), contract, or otherwise, shall any Contributor, or anyone who
distributes Covered Software as permitted above, be liable to You for any
direct, indirect, special, incidental, or consequential damages of any
character including, without limitation, damages for lost profits, loss of
goodwill, work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses, even if such party shall have been
informed of the possibility of such damages. This limitation of liability
shall not apply to liability for death or personal injury resulting from
such party's negligence to the extent applicable law prohibits such
limitation. Some jurisdictions do not allow the exclusion or limitation of
incidental or consequential damages, so this exclusion and limitation may
not apply to You.
8. Litigation
Any litigation relating to this License may be brought only in the courts
of a jurisdiction where the defendant maintains its principal place of
business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions. Nothing
in this Section shall prevent a party's ability to bring cross-claims or
counter-claims.
9. Miscellaneous
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides that
the language of a contract shall be construed against the drafter shall not
be used to construe this License against a Contributor.
10. Versions of the License
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses If You choose to distribute Source Code Form that is
Incompatible With Secondary Licenses under the terms of this version of
the License, the notice described in Exhibit B of this License must be
attached.
Exhibit A - Source Code Form License Notice
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular file,
then You may include the notice in a location (such as a LICENSE file in a
relevant directory) where a recipient would be likely to look for such a
notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
This Source Code Form is "Incompatible
With Secondary Licenses", as defined by
the Mozilla Public License, v. 2.0.

View File

@@ -0,0 +1,111 @@
package gcputil
import (
"fmt"
"google.golang.org/api/compute/v1"
"regexp"
"time"
)
func ParseGcpLabels(labels []string) (parsed map[string]string, invalid []string) {
parsed = map[string]string{}
invalid = []string{}
re := regexp.MustCompile(labelRegex)
for _, labelStr := range labels {
matches := re.FindStringSubmatch(labelStr)
if len(matches) == 0 {
invalid = append(invalid, labelStr)
continue
}
captureNames := re.SubexpNames()
var keyPtr, valPtr *string
for i, name := range captureNames {
if name == "key" {
keyPtr = &matches[i]
} else if name == "value" {
valPtr = &matches[i]
}
}
if keyPtr == nil || valPtr == nil || len(*keyPtr) < 1 {
invalid = append(invalid, labelStr)
continue
} else {
parsed[*keyPtr] = *valPtr
}
}
return parsed, invalid
}
type CustomJWTClaims struct {
Google *GoogleJWTClaims `json:"google,omitempty"`
}
type GoogleJWTClaims struct {
Compute *GCEIdentityMetadata `json:"compute_engine,omitempty"`
}
type GCEIdentityMetadata struct {
// ProjectId is the ID for the project where you created the instance.
ProjectId string `json:"project_id" structs:"project_id" mapstructure:"project_id"`
// ProjectNumber is the unique ID for the project where you created the instance.
ProjectNumber int64 `json:"project_number" structs:"project_number" mapstructure:"project_number"`
// Zone is the zone where the instance is located.
Zone string `json:"zone" structs:"zone" mapstructure:"zone"`
// InstanceId is the unique ID for the instance to which this token belongs. This ID is unique and never reused.
InstanceId string `json:"instance_id" structs:"instance_id" mapstructure:"instance_id"`
// InstanceName is the name of the instance to which this token belongs. This name can be reused by several
// instances over time, so use the instance_id value to identify a unique instance ID.
InstanceName string `json:"instance_name" structs:"instance_name" mapstructure:"instance_name"`
// CreatedAt is a unix timestamp indicating when you created the instance.
CreatedAt int64 `json:"instance_creation_timestamp" structs:"instance_creation_timestamp" mapstructure:"instance_creation_timestamp"`
}
// GetVerifiedInstance returns the Instance as described by the identity metadata or an error.
// If the instance has an invalid status or its creation timestamp does not match the metadata value,
// this will return nil and an error.
func (meta *GCEIdentityMetadata) GetVerifiedInstance(gceClient *compute.Service) (*compute.Instance, error) {
instance, err := gceClient.Instances.Get(meta.ProjectId, meta.Zone, meta.InstanceName).Do()
if err != nil {
return nil, fmt.Errorf("unable to find instance associated with token: %v", err)
}
if !IsValidInstanceStatus(instance.Status) {
return nil, fmt.Errorf("authenticating instance %s found but has invalid status '%s'", instance.Name, instance.Status)
}
// Parse the metadata CreatedAt into time.
metaTime := time.Unix(meta.CreatedAt, 0)
// Parse instance creationTimestamp into time.
actualTime, err := time.Parse(time.RFC3339Nano, instance.CreationTimestamp)
if err != nil {
return nil, fmt.Errorf("instance 'creationTimestamp' field could not be parsed into time: %s", instance.CreationTimestamp)
}
// Return an error if the metadata creation timestamp is before the instance creation timestamp.
delta := float64(metaTime.Sub(actualTime)) / float64(time.Second)
if delta < -1 {
return nil, fmt.Errorf("metadata instance_creation_timestamp %d is before instance's creation time %d", actualTime.Unix(), metaTime.Unix())
}
return instance, nil
}
var validInstanceStates map[string]struct{} = map[string]struct{}{
"PROVISIONING": struct{}{},
"RUNNING": struct{}{},
"STAGING": struct{}{},
}
func IsValidInstanceStatus(status string) bool {
_, ok := validInstanceStates[status]
return ok
}

View File

@@ -0,0 +1,178 @@
package gcputil
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"github.com/hashicorp/go-cleanhttp"
"github.com/mitchellh/go-homedir"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
googleoauth2 "google.golang.org/api/oauth2/v2"
"gopkg.in/square/go-jose.v2"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
)
const (
labelRegex string = "^(?P<key>[a-z]([\\w-]+)?):(?P<value>[\\w-]*)$"
defaultHomeCredentialsFile = ".gcp/credentials"
)
// GcpCredentials represents a simplified version of the Google Cloud Platform credentials file format.
type GcpCredentials struct {
ClientEmail string `json:"client_email" structs:"client_email" mapstructure:"client_email"`
ClientId string `json:"client_id" structs:"client_id" mapstructure:"client_id"`
PrivateKeyId string `json:"private_key_id" structs:"private_key_id" mapstructure:"private_key_id"`
PrivateKey string `json:"private_key" structs:"private_key" mapstructure:"private_key"`
ProjectId string `json:"project_id" structs:"project_id" mapstructure:"project_id"`
}
// FindCredentials attempts to obtain GCP credentials in the
// following ways:
// * Parse JSON from provided credentialsJson
// * Parse JSON from the environment variables GOOGLE_CREDENTIALS or GOOGLE_CLOUD_KEYFILE_JSON
// * Parse JSON file ~/.gcp/credentials
// * Google Application Default Credentials (see https://developers.google.com/identity/protocols/application-default-credentials)
func FindCredentials(credsJson string, ctx context.Context, scopes ...string) (*GcpCredentials, oauth2.TokenSource, error) {
var creds *GcpCredentials
var err error
// 1. Parse JSON from provided credentialsJson
if credsJson == "" {
// 2. JSON from env var GOOGLE_CREDENTIALS
credsJson = os.Getenv("GOOGLE_CREDENTIALS")
}
if credsJson == "" {
// 3. JSON from env var GOOGLE_CLOUD_KEYFILE_JSON
credsJson = os.Getenv("GOOGLE_CLOUD_KEYFILE_JSON")
}
if credsJson == "" {
// 4. JSON from ~/.gcp/credentials
home, err := homedir.Dir()
if err == nil {
return nil, nil, errors.New("could not find home directory")
}
credBytes, err := ioutil.ReadFile(filepath.Join(home, defaultHomeCredentialsFile))
if err == nil {
credsJson = string(credBytes)
}
}
// Parse JSON into credentials.
if credsJson != "" {
creds, err = Credentials(credsJson)
if err == nil {
conf := jwt.Config{
Email: creds.ClientEmail,
PrivateKey: []byte(creds.PrivateKey),
Scopes: scopes,
TokenURL: "https://accounts.google.com/o/oauth2/token",
}
return creds, conf.TokenSource(ctx), nil
}
}
// 5. Use Application default credentials.
defaultCreds, err := google.FindDefaultCredentials(ctx, scopes...)
if err != nil {
return nil, nil, err
}
if defaultCreds.JSON != nil {
creds, err = Credentials(string(defaultCreds.JSON))
if err != nil {
return nil, nil, errors.New("could not read credentials from application default credential JSON")
}
}
return creds, defaultCreds.TokenSource, nil
}
// Credentials attempts to parse GcpCredentials from a JSON string.
func Credentials(credentialsJson string) (*GcpCredentials, error) {
credentials := &GcpCredentials{}
if err := json.Unmarshal([]byte(credentialsJson), &credentials); err != nil {
return nil, err
}
return credentials, nil
}
// GetHttpClient creates an HTTP client from the given Google credentials and scopes.
func GetHttpClient(credentials *GcpCredentials, clientScopes ...string) (*http.Client, error) {
conf := jwt.Config{
Email: credentials.ClientEmail,
PrivateKey: []byte(credentials.PrivateKey),
Scopes: clientScopes,
TokenURL: "https://accounts.google.com/o/oauth2/token",
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, cleanhttp.DefaultClient())
client := conf.Client(ctx)
return client, nil
}
// PublicKey returns a public key from a Google PEM key file (type TYPE_X509_PEM_FILE).
func PublicKey(pemString string) (interface{}, error) {
pemBytes, err := base64.StdEncoding.DecodeString(pemString)
if err != nil {
return nil, err
}
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("Unable to find pem block in key")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
return cert.PublicKey, nil
}
// OAuth2RSAPublicKey returns the PEM key file string for Google Oauth2 public cert for the given 'kid' id.
func OAuth2RSAPublicKey(kid, oauth2BasePath string) (interface{}, error) {
oauth2Client, err := googleoauth2.New(cleanhttp.DefaultClient())
if err != nil {
return "", err
}
if len(oauth2BasePath) > 0 {
oauth2Client.BasePath = oauth2BasePath
}
jwks, err := oauth2Client.GetCertForOpenIdConnect().Do()
if err != nil {
return nil, err
}
for _, key := range jwks.Keys {
if key.Kid == kid && jose.SignatureAlgorithm(key.Alg) == jose.RS256 {
// Trim extra '=' from key so it can be parsed.
key.N = strings.TrimRight(key.N, "=")
js, err := key.MarshalJSON()
if err != nil {
return nil, fmt.Errorf("unable to marshal json %v", err)
}
key := &jose.JSONWebKey{}
if err := key.UnmarshalJSON(js); err != nil {
return nil, fmt.Errorf("unable to unmarshal json %v", err)
}
return key.Key, nil
}
}
return nil, fmt.Errorf("could not find public key with kid '%s'", kid)
}

View File

@@ -0,0 +1,51 @@
package gcputil
import (
"fmt"
"google.golang.org/api/iam/v1"
)
const (
ServiceAccountTemplate = "projects/%s/serviceAccounts/%s"
ServiceAccountKeyTemplate = "projects/%s/serviceAccounts/%s/keys/%s"
ServiceAccountKeyFileType = "TYPE_X509_PEM_FILE"
)
type ServiceAccountId struct {
Project string
EmailOrId string
}
func (id *ServiceAccountId) ResourceName() string {
return fmt.Sprintf(ServiceAccountTemplate, id.Project, id.EmailOrId)
}
type ServiceAccountKeyId struct {
Project string
EmailOrId string
Key string
}
func (id *ServiceAccountKeyId) ResourceName() string {
return fmt.Sprintf(ServiceAccountKeyTemplate, id.Project, id.EmailOrId, id.Key)
}
// ServiceAccount wraps a call to the GCP IAM API to get a service account.
func ServiceAccount(iamClient *iam.Service, accountId *ServiceAccountId) (*iam.ServiceAccount, error) {
account, err := iamClient.Projects.ServiceAccounts.Get(accountId.ResourceName()).Do()
if err != nil {
return nil, fmt.Errorf("could not find service account '%s': %v", accountId.ResourceName(), err)
}
return account, nil
}
// ServiceAccountKey wraps a call to the GCP IAM API to get a service account key.
func ServiceAccountKey(iamClient *iam.Service, keyId *ServiceAccountKeyId) (*iam.ServiceAccountKey, error) {
keyResource := keyId.ResourceName()
key, err := iamClient.Projects.ServiceAccounts.Keys.Get(keyId.ResourceName()).PublicKeyType(ServiceAccountKeyFileType).Do()
if err != nil {
return nil, fmt.Errorf("could not find service account key '%s': %v", keyResource, err)
}
return key, nil
}

View File

@@ -0,0 +1,124 @@
package gcputil
import (
"fmt"
"net/url"
"regexp"
"strings"
)
const (
resourceIdRegex = "^[^\t\n\f\r]+$"
collectionIdRegex = "^[a-z][a-zA-Z]*$"
fullResourceNameRegex = "^//([a-z]+).googleapis.com/(.+)$"
selfLinkMarker = "projects/"
)
var singleCollectionIds = map[string]struct{}{
"global": {},
}
type RelativeResourceName struct {
Name string
TypeKey string
IdTuples map[string]string
}
func ParseRelativeName(resource string) (*RelativeResourceName, error) {
resourceRe := regexp.MustCompile(resourceIdRegex)
collectionRe := regexp.MustCompile(collectionIdRegex)
tokens := strings.Split(resource, "/")
if len(tokens) < 2 {
return nil, fmt.Errorf("invalid relative resource name %s (too few tokens)", resource)
}
ids := map[string]string{}
typeKey := ""
currColId := ""
for idx, v := range tokens {
if len(currColId) == 0 {
if _, ok := singleCollectionIds[v]; ok {
// Ignore 'single' collectionIds like Global, but error if they are the last ID
if idx == len(tokens)-1 {
return nil, fmt.Errorf("invalid relative resource name %s (last collection '%s' has no ID)", resource, currColId)
}
continue
}
if len(collectionRe.FindAllString(v, 1)) == 0 {
return nil, fmt.Errorf("invalid relative resource name %s (invalid collection ID %s)", resource, v)
}
currColId = v
typeKey += currColId + "/"
} else {
if len(resourceRe.FindAllString(v, 1)) == 0 {
return nil, fmt.Errorf("invalid relative resource name %s (invalid resource sub-ID %s)", resource, v)
}
ids[currColId] = v
currColId = ""
}
}
resourceName := tokens[len(tokens)-2]
return &RelativeResourceName{
Name: resourceName,
TypeKey: typeKey[:len(typeKey)-1],
IdTuples: ids,
}, nil
}
type FullResourceName struct {
Service string
*RelativeResourceName
}
func ParseFullResourceName(name string) (*FullResourceName, error) {
fullRe := regexp.MustCompile(fullResourceNameRegex)
matches := fullRe.FindAllStringSubmatch(name, 1)
if len(matches) == 0 {
return nil, fmt.Errorf("invalid full name '%s'", name)
}
if len(matches[0]) != 3 {
return nil, fmt.Errorf("invalid full name '%s'", name)
}
serviceName := matches[0][1]
relName, err := ParseRelativeName(strings.Trim(matches[0][2], "/"))
if err != nil {
return nil, fmt.Errorf("error parsing relative resource path in full resource name '%s': %v", name, err)
}
return &FullResourceName{
Service: serviceName,
RelativeResourceName: relName,
}, nil
}
type SelfLink struct {
Prefix string
*RelativeResourceName
}
func ParseProjectResourceSelfLink(link string) (*SelfLink, error) {
u, err := url.Parse(link)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, fmt.Errorf("invalid self link '%s' must have scheme/host", link)
}
split := strings.SplitAfterN(link, selfLinkMarker, 2)
if len(split) != 2 {
return nil, fmt.Errorf("self link '%s' is not for project-level resource, must contain '%s')", link, selfLinkMarker)
}
relName, err := ParseRelativeName(selfLinkMarker + split[1])
if err != nil {
return nil, fmt.Errorf("error parsing relative resource path in self-link '%s': %v", link, err)
}
return &SelfLink{
Prefix: strings.TrimSuffix(split[0], selfLinkMarker),
RelativeResourceName: relName,
}, nil
}

View File

@@ -0,0 +1,363 @@
Mozilla Public License, version 2.0
1. Definitions
1.1. "Contributor"
means each individual or legal entity that creates, contributes to the
creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used by a
Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached the
notice in Exhibit A, the Executable Form of such Source Code Form, and
Modifications of such Source Code Form, in each case including portions
thereof.
1.5. "Incompatible With Secondary Licenses"
means
a. that the initial Contributor has attached the notice described in
Exhibit B to the Covered Software; or
b. that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the terms of
a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in a
separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible, whether
at the time of the initial grant or subsequently, any and all of the
rights conveyed by this License.
1.10. "Modifications"
means any of the following:
a. any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered Software; or
b. any new file in Source Code Form that contains any Covered Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the License,
by the making, using, selling, offering for sale, having made, import,
or transfer of either its Contributions or its Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU Lesser
General Public License, Version 2.1, the GNU Affero General Public
License, Version 3.0, or any later versions of those licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that controls, is
controlled by, or is under common control with You. For purposes of this
definition, "control" means (a) the power, direct or indirect, to cause
the direction or management of such entity, whether by contract or
otherwise, or (b) ownership of more than fifty percent (50%) of the
outstanding shares or beneficial ownership of such entity.
2. License Grants and Conditions
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
a. under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
b. under Patent Claims of such Contributor to make, use, sell, offer for
sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
a. for any code that a Contributor has removed from Covered Software; or
b. for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
c. under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights to
grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in
Section 2.1.
3. Responsibilities
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
a. such Covered Software must also be made available in Source Code Form,
as described in Section 3.1, and You must inform recipients of the
Executable Form how they can obtain a copy of such Source Code Form by
reasonable means in a timely manner, at a charge no more than the cost
of distribution to the recipient; and
b. You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter the
recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty, or
limitations of liability) contained within the Source Code Form of the
Covered Software, except that You may alter any license notices to the
extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
If it is impossible for You to comply with any of the terms of this License
with respect to some or all of the Covered Software due to statute,
judicial order, or regulation then You must: (a) comply with the terms of
this License to the maximum extent possible; and (b) describe the
limitations and the code they affect. Such description must be placed in a
text file included with all distributions of the Covered Software under
this License. Except to the extent prohibited by statute or regulation,
such description must be sufficiently detailed for a recipient of ordinary
skill to be able to understand it.
5. Termination
5.1. The rights granted under this License will terminate automatically if You
fail to comply with any of its terms. However, if You become compliant,
then the rights granted under this License from a particular Contributor
are reinstated (a) provisionally, unless and until such Contributor
explicitly and finally terminates Your grants, and (b) on an ongoing
basis, if such Contributor fails to notify You of the non-compliance by
some reasonable means prior to 60 days after You have come back into
compliance. Moreover, Your grants from a particular Contributor are
reinstated on an ongoing basis if such Contributor notifies You of the
non-compliance by some reasonable means, this is the first time You have
received notice of non-compliance with this License from such
Contributor, and You become compliant prior to 30 days after Your receipt
of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user
license agreements (excluding distributors and resellers) which have been
validly granted by You or Your distributors under this License prior to
termination shall survive termination.
6. Disclaimer of Warranty
Covered Software is provided under this License on an "as is" basis,
without warranty of any kind, either expressed, implied, or statutory,
including, without limitation, warranties that the Covered Software is free
of defects, merchantable, fit for a particular purpose or non-infringing.
The entire risk as to the quality and performance of the Covered Software
is with You. Should any Covered Software prove defective in any respect,
You (not any Contributor) assume the cost of any necessary servicing,
repair, or correction. This disclaimer of warranty constitutes an essential
part of this License. No use of any Covered Software is authorized under
this License except under this disclaimer.
7. Limitation of Liability
Under no circumstances and under no legal theory, whether tort (including
negligence), contract, or otherwise, shall any Contributor, or anyone who
distributes Covered Software as permitted above, be liable to You for any
direct, indirect, special, incidental, or consequential damages of any
character including, without limitation, damages for lost profits, loss of
goodwill, work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses, even if such party shall have been
informed of the possibility of such damages. This limitation of liability
shall not apply to liability for death or personal injury resulting from
such party's negligence to the extent applicable law prohibits such
limitation. Some jurisdictions do not allow the exclusion or limitation of
incidental or consequential damages, so this exclusion and limitation may
not apply to You.
8. Litigation
Any litigation relating to this License may be brought only in the courts
of a jurisdiction where the defendant maintains its principal place of
business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions. Nothing
in this Section shall prevent a party's ability to bring cross-claims or
counter-claims.
9. Miscellaneous
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides that
the language of a contract shall be construed against the drafter shall not
be used to construe this License against a Contributor.
10. Versions of the License
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses If You choose to distribute Source Code Form that is
Incompatible With Secondary Licenses under the terms of this version of
the License, the notice described in Exhibit B of this License must be
attached.
Exhibit A - Source Code Form License Notice
This Source Code Form is subject to the
terms of the Mozilla Public License, v.
2.0. If a copy of the MPL was not
distributed with this file, You can
obtain one at
http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular file,
then You may include the notice in a location (such as a LICENSE file in a
relevant directory) where a recipient would be likely to look for such a
notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
This Source Code Form is "Incompatible
With Secondary Licenses", as defined by
the Mozilla Public License, v. 2.0.

View File

@@ -0,0 +1,114 @@
package gcpsecrets
import (
"context"
"github.com/hashicorp/go-cleanhttp"
"github.com/hashicorp/go-gcp-common/gcputil"
"github.com/hashicorp/vault-plugin-secrets-gcp/plugin/iamutil"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
"golang.org/x/oauth2"
"google.golang.org/api/iam/v1"
"net/http"
"strings"
"sync"
"time"
)
type backend struct {
*framework.Backend
enabledIamResources iamutil.EnabledResources
rolesetLock sync.Mutex
}
func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
b := Backend()
if err := b.Setup(ctx, conf); err != nil {
return nil, err
}
return b, nil
}
func Backend() *backend {
var b = backend{
enabledIamResources: iamutil.GetEnabledIamResources(),
}
b.Backend = &framework.Backend{
Help: strings.TrimSpace(backendHelp),
PathsSpecial: &logical.Paths{
LocalStorage: []string{
framework.WALPrefix,
},
SealWrapStorage: []string{
"config",
},
},
Paths: framework.PathAppend(
pathsRoleSet(&b),
[]*framework.Path{
pathConfig(&b),
pathSecretAccessToken(&b),
pathSecretServiceAccountKey(&b),
},
),
Secrets: []*framework.Secret{
secretAccessToken(&b),
secretServiceAccountKey(&b),
},
BackendType: logical.TypeLogical,
WALRollback: b.walRollback,
WALRollbackMinAge: 5 * time.Minute,
}
return &b
}
func newHttpClient(ctx context.Context, s logical.Storage, scopes ...string) (*http.Client, error) {
if len(scopes) == 0 {
scopes = []string{"https://www.googleapis.com/auth/cloud-platform"}
}
cfg, err := getConfig(ctx, s)
if err != nil {
return nil, err
}
credsJSON := ""
if cfg != nil {
credsJSON = cfg.CredentialsRaw
}
_, tokenSource, err := gcputil.FindCredentials(credsJSON, ctx, scopes...)
if err != nil {
return nil, err
}
tc := cleanhttp.DefaultClient()
return oauth2.NewClient(
context.WithValue(ctx, oauth2.HTTPClient, tc),
tokenSource), nil
}
func newIamAdmin(ctx context.Context, s logical.Storage) (*iam.Service, error) {
c, err := newHttpClient(ctx, s, iam.CloudPlatformScope)
if err != nil {
return nil, err
}
return iam.New(c)
}
const backendHelp = `
The GCP secrets backend dynamically generates GCP IAM service
account keys with a given set of IAM policies. The service
account keys have a configurable lease set and are automatically
revoked at the end of the lease.
After mounting this backend, credentials to generate IAM keys must
be configured with the "config/" endpoints and policies must be
written using the "roles/" endpoints before any keys can be generated.
`

View File

@@ -0,0 +1,77 @@
package iamutil
import (
"context"
"encoding/json"
"github.com/hashicorp/errwrap"
"google.golang.org/api/gensupport"
"google.golang.org/api/googleapi"
"net/http"
)
type IamHandle struct {
c *http.Client
userAgent string
}
func GetIamHandle(client *http.Client, userAgent string) *IamHandle {
return &IamHandle{
c: client,
userAgent: userAgent,
}
}
func (h *IamHandle) GetIamPolicy(ctx context.Context, r IamResource) (*Policy, error) {
req, err := r.GetIamPolicyRequest()
if err != nil {
return nil, errwrap.Wrapf("unable to construct GetIamPolicy request: {{err}}", err)
}
var p Policy
if err := h.doRequest(ctx, req, &p); err != nil {
return nil, errwrap.Wrapf("unable to get policy: {{err}}", err)
}
return &p, nil
}
func (h *IamHandle) SetIamPolicy(ctx context.Context, r IamResource, p *Policy) (*Policy, error) {
req, err := r.SetIamPolicyRequest(p)
if err != nil {
return nil, errwrap.Wrapf("unable to construct SetIamPolicy request: {{err}}", err)
}
var out Policy
if err := h.doRequest(ctx, req, &out); err != nil {
return nil, errwrap.Wrapf("unable to set policy: {{err}}", err)
}
return &out, nil
}
func (h *IamHandle) doRequest(ctx context.Context, req *http.Request, out interface{}) error {
if req.Header == nil {
req.Header = make(http.Header)
}
if h.userAgent != "" {
req.Header.Set("User-Agent", h.userAgent)
}
resp, err := gensupport.SendRequest(ctx, h.c, req)
defer googleapi.CloseBody(resp)
if resp != nil && resp.StatusCode == http.StatusNotModified {
return &googleapi.Error{
Code: resp.StatusCode,
Header: resp.Header,
}
}
if err != nil {
return err
}
if err := googleapi.CheckResponse(resp); err != nil {
return err
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return errwrap.Wrapf("unable to decode JSON resp to output interface: {{err}}", err)
}
return nil
}

View File

@@ -0,0 +1,100 @@
package iamutil
import (
"fmt"
"github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util"
)
const (
ServiceAccountMemberTmpl = "serviceAccount:%s"
)
type Policy struct {
Bindings []*Binding `json:"bindings,omitempty"`
Etag string `json:"etag,omitempty"`
}
type Binding struct {
Members []string `json:"members,omitempty"`
Role string `json:"role,omitempty"`
}
type PolicyDelta struct {
Roles util.StringSet
Email string
}
func (p *Policy) AddBindings(toAdd *PolicyDelta) (changed bool, updated *Policy) {
return p.ChangedBindings(toAdd, nil)
}
func (p *Policy) RemoveBindings(toRemove *PolicyDelta) (changed bool, updated *Policy) {
return p.ChangedBindings(nil, toRemove)
}
func (p *Policy) ChangedBindings(toAdd *PolicyDelta, toRemove *PolicyDelta) (changed bool, updated *Policy) {
if toAdd == nil && toRemove == nil {
return false, p
}
var toAddMem, toRemoveMem string
if toAdd != nil {
toAddMem = fmt.Sprintf(ServiceAccountMemberTmpl, toAdd.Email)
}
if toRemove != nil {
toRemoveMem = fmt.Sprintf(ServiceAccountMemberTmpl, toRemove.Email)
}
changed = false
newBindings := make([]*Binding, 0, len(p.Bindings))
alreadyAdded := make(util.StringSet)
for _, bind := range p.Bindings {
memberSet := util.ToSet(bind.Members)
if toAdd != nil {
if toAdd.Roles.Includes(bind.Role) {
changed = true
alreadyAdded.Add(bind.Role)
memberSet.Add(toAddMem)
}
}
if toRemove != nil {
if toRemove.Roles.Includes(bind.Role) {
if memberSet.Includes(toRemoveMem) {
changed = true
delete(memberSet, toRemoveMem)
}
}
}
if len(memberSet) > 0 {
newBindings = append(newBindings, &Binding{
Role: bind.Role,
Members: memberSet.ToSlice(),
})
}
}
if toAdd != nil {
for r := range toAdd.Roles {
if !alreadyAdded.Includes(r) {
changed = true
newBindings = append(newBindings, &Binding{
Role: r,
Members: []string{toAddMem},
})
}
}
}
if changed {
return true, &Policy{
Bindings: newBindings,
Etag: p.Etag,
}
}
return false, p
}

View File

@@ -0,0 +1,253 @@
//go:generate go run internal/generate_iam_resources.go
package iamutil
import (
"errors"
"fmt"
"github.com/hashicorp/go-gcp-common/gcputil"
"google.golang.org/api/googleapi"
"io"
"net/http"
"net/url"
"strings"
)
const (
resourceParsingErrorTmpl = `invalid resource "%s": %v`
resourceMultipleServicesTmpl = `please provide a self-link or full resource name for non-service-unique resource type '%s' (supported services: %s)`
resourceMultipleVersions = `please provide a self-link with version instead; IAM support for this resource is for multiple non-preferred service versions`
)
type EnabledResources interface {
Resource(resource string) (IamResource, error)
}
type iamResourceMap map[string]map[string]map[string]*IamResourceConfig
type generatedIamResources struct {
resources iamResourceMap
}
func (apis *generatedIamResources) parseResource(name string) (*gcputil.RelativeResourceName, *IamResourceConfig, error) {
rUrl, err := url.Parse(name)
if err != nil {
return nil, nil, fmt.Errorf(`resource "%s" is invalid URI`, name)
}
var relName *gcputil.RelativeResourceName
var hasServiceVersion bool
var serviceName string
if rUrl.Scheme != "" {
selfLink, err := gcputil.ParseProjectResourceSelfLink(name)
if err != nil {
return nil, nil, err
}
hasServiceVersion = true
relName = selfLink.RelativeResourceName
} else if rUrl.Host != "" {
fullName, err := gcputil.ParseFullResourceName(name)
if err != nil {
return nil, nil, err
}
relName = fullName.RelativeResourceName
serviceName = fullName.Service
} else {
relName, err = gcputil.ParseRelativeName(name)
if err != nil {
return nil, nil, err
}
}
if relName == nil {
return nil, nil, fmt.Errorf(resourceParsingErrorTmpl, name, "unable to parse relative name")
}
serviceMap, ok := apis.resources[relName.TypeKey]
if !ok {
return nil, nil, fmt.Errorf(resourceParsingErrorTmpl, name, fmt.Errorf("unsupported resource type: %s", relName.TypeKey))
}
var resConfig *IamResourceConfig
if hasServiceVersion {
resConfig, err = tryGetConfigForSelfLink(name, relName.TypeKey, serviceMap)
} else {
resConfig, err = tryGetUniqueVersion(serviceName, relName.TypeKey, serviceMap)
}
if err != nil {
return nil, nil, err
}
if resConfig == nil {
return nil, nil, fmt.Errorf(resourceParsingErrorTmpl, name, "unable to get IAM resource config")
}
return relName, resConfig, nil
}
type IamResource interface {
GetIamPolicyRequest() (*http.Request, error)
SetIamPolicyRequest(*Policy) (*http.Request, error)
}
func (apis *generatedIamResources) Resource(name string) (IamResource, error) {
relName, cfg, err := apis.parseResource(name)
if err != nil {
return nil, err
}
return &iamResourceImpl{
relativeId: relName,
config: cfg,
}, nil
}
func tryGetConfigForSelfLink(link, typeKey string, resourceServices map[string]map[string]*IamResourceConfig) (*IamResourceConfig, error) {
for _, verMap := range resourceServices {
for _, resourceCfg := range verMap {
prefix := resourceCfg.Service.RootUrl + resourceCfg.Service.ServicePath
if strings.HasPrefix(link, prefix) {
return resourceCfg, nil
}
}
}
return nil, fmt.Errorf("could not find service/version given in self-link for resource type %s", typeKey)
}
func tryGetUniqueVersion(serviceName, typeKey string, resourceServices map[string]map[string]*IamResourceConfig) (*IamResourceConfig, error) {
if serviceName == "" {
return tryGetUniqueServiceAndVersion(typeKey, resourceServices)
}
if resourceServices == nil {
return nil, fmt.Errorf("no supported services for %s", typeKey)
}
verMap, hasService := resourceServices[serviceName]
if !hasService {
return nil, fmt.Errorf("unsupported service '%s' for resource type: %s", serviceName, typeKey)
}
return getResourceFromVersions(verMap)
}
func tryGetUniqueServiceAndVersion(typeKey string, resourceServices map[string]map[string]*IamResourceConfig) (*IamResourceConfig, error) {
if resourceServices == nil || len(resourceServices) < 1 {
return nil, fmt.Errorf("no supported services for %s", typeKey)
}
isUnique := len(resourceServices) == 1
supported := ""
for serviceName, verMap := range resourceServices {
supported += serviceName + ", "
if isUnique {
return getResourceFromVersions(verMap)
}
}
return nil, fmt.Errorf(resourceMultipleServicesTmpl, typeKey, strings.Trim(supported, ", "))
}
func getResourceFromVersions(versionsMap map[string]*IamResourceConfig) (*IamResourceConfig, error) {
var preferredVer *IamResourceConfig
var onlyCfg *IamResourceConfig
for _, onlyCfg = range versionsMap {
if onlyCfg.Service.IsPreferredVersion {
preferredVer = onlyCfg
break
}
}
if preferredVer != nil {
return preferredVer, nil
} else if len(versionsMap) == 1 {
return onlyCfg, nil
} else {
return nil, errors.New(resourceMultipleVersions)
}
}
type iamResourceImpl struct {
relativeId *gcputil.RelativeResourceName
config *IamResourceConfig
}
type IamResourceConfig struct {
// Service this resource belongs to
Service *ServiceConfig
// Config for IAM Methods
SetIamPolicy *HttpMethodCfg
GetIamPolicy *HttpMethodCfg
}
type HttpMethodCfg struct {
// HTTP method, e.g. GET/PUT/POST
HttpMethod string `json:"httpMethod"`
// Path is the API method's path with replacement keys, e.g.
// v1/projects/{project}:getIamPolicy
Path string `json:"flatPath"`
// ReplacementKeys maps collectionIds in the expected resource format to the key for googleapis.Expand
// For example, given input of:
// Resource: "projects/my-project/zones/my-zone/instances/someInstance"
// Method Path: "p/{projectId}/z/{zoneId}/i/{resource}"
//
// This would be:
// map[string]string{
// "projects": "projectId" ,
// "zones": "zoneId",
// "instances": "resource"
// }
ReplacementKeys map[string]string
}
type ServiceConfig struct {
// API service Name (e.g. "compute", "iam", "pubsub")
Name string
// API service Version.
Version string
// IsPreferredVersion is
IsPreferredVersion bool
// Root URL + Service Path is the prefix for all calls using this service.
RootUrl string
ServicePath string
}
func (r *iamResourceImpl) SetIamPolicyRequest(p *Policy) (*http.Request, error) {
data := struct {
Policy *Policy `json:"policy,omitempty"`
}{Policy: p}
buf, err := googleapi.WithoutDataWrapper.JSONReader(data)
if err != nil {
return nil, err
}
return r.constructRequest(r.config.SetIamPolicy, buf)
}
func (r *iamResourceImpl) GetIamPolicyRequest() (*http.Request, error) {
return r.constructRequest(r.config.GetIamPolicy, nil)
}
func (r *iamResourceImpl) constructRequest(httpMtd *HttpMethodCfg, data io.Reader) (*http.Request, error) {
reqUrl := googleapi.ResolveRelative(r.config.Service.RootUrl+r.config.Service.ServicePath, httpMtd.Path)
req, err := http.NewRequest(httpMtd.HttpMethod, reqUrl, data)
if err != nil {
return nil, err
}
replacementMap := make(map[string]string)
for cId, replaceK := range httpMtd.ReplacementKeys {
rId, ok := r.relativeId.IdTuples[cId]
if !ok {
return nil, fmt.Errorf("expected value for collection id %s", cId)
}
replacementMap[replaceK] = rId
}
googleapi.Expand(req.URL, replacementMap)
return req, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
package gcpsecrets
import (
"context"
"fmt"
"github.com/hashicorp/go-gcp-common/gcputil"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
"time"
)
const (
cfgReadWarning = "omitted sensitive credentials from read output"
)
func pathConfig(b *backend) *framework.Path {
return &framework.Path{
Pattern: "config",
Fields: map[string]*framework.FieldSchema{
"credentials": {
Type: framework.TypeString,
Description: `GCP IAM service account credentials JSON with permissions to create new service accounts and set IAM policies`,
},
"ttl": {
Type: framework.TypeDurationSecond,
Description: "Default lease for generated keys. If <= 0, will use system default.",
},
"max_ttl": {
Type: framework.TypeDurationSecond,
Description: "Maximum time a service account key is valid for. If <= 0, will use system default.",
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathConfigRead,
logical.UpdateOperation: b.pathConfigWrite,
},
HelpSynopsis: pathConfigHelpSyn,
HelpDescription: pathConfigHelpDesc,
}
}
func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
cfg, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, nil
}
return &logical.Response{
Data: map[string]interface{}{
"ttl": int64(cfg.TTL / time.Second),
"max_ttl": int64(cfg.MaxTTL / time.Second),
},
Warnings: []string{cfgReadWarning},
}, nil
}
func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
cfg, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if cfg == nil {
cfg = &config{}
}
credentialsRaw, ok := data.GetOk("credentials")
if ok {
_, err := gcputil.Credentials(credentialsRaw.(string))
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("invalid credentials JSON file: %v", err)), nil
}
cfg.CredentialsRaw = credentialsRaw.(string)
}
// Update token TTL.
ttlRaw, ok := data.GetOk("ttl")
if ok {
cfg.TTL = time.Duration(ttlRaw.(int)) * time.Second
}
// Update token Max TTL.
maxTTLRaw, ok := data.GetOk("max_ttl")
if ok {
cfg.MaxTTL = time.Duration(maxTTLRaw.(int)) * time.Second
}
entry, err := logical.StorageEntryJSON("config", cfg)
if err != nil {
return nil, err
}
if err := req.Storage.Put(ctx, entry); err != nil {
return nil, err
}
return nil, nil
}
type config struct {
CredentialsRaw string
TTL time.Duration
MaxTTL time.Duration
}
func getConfig(ctx context.Context, s logical.Storage) (*config, error) {
var cfg config
cfgRaw, err := s.Get(ctx, "config")
if err != nil {
return nil, err
}
if cfgRaw == nil {
return nil, nil
}
if err := cfgRaw.DecodeJSON(&cfg); err != nil {
return nil, err
}
return &cfg, err
}
const pathConfigHelpSyn = `
Configure the GCP backend.
`
const pathConfigHelpDesc = `
The GCP backend requires credentials for managing IAM service accounts and keys
and IAM policies on various GCP resources. This endpoint is used to configure
those credentials as well as default values for the backend in general.
`

View File

@@ -0,0 +1,520 @@
package gcpsecrets
import (
"context"
"errors"
"fmt"
"github.com/hashicorp/errwrap"
"github.com/hashicorp/vault-plugin-secrets-gcp/plugin/iamutil"
"github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util"
"github.com/hashicorp/vault/helper/useragent"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
"google.golang.org/api/iam/v1"
)
const (
rolesetStoragePrefix = "roleset"
)
func pathsRoleSet(b *backend) []*framework.Path {
return []*framework.Path{
{
Pattern: fmt.Sprintf("roleset/%s", framework.GenericNameRegex("name")),
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeString,
Description: "Required. Name of the role.",
},
"secret_type": {
Type: framework.TypeString,
Description: fmt.Sprintf("Type of secret generated for this role set. Defaults to '%s'", SecretTypeAccessToken),
Default: SecretTypeAccessToken,
},
"project": {
Type: framework.TypeString,
Description: "Name of the GCP project that this roleset's service account will belong to.",
},
"bindings": {
Type: framework.TypeString,
Description: "Bindings configuration string.",
},
"token_scopes": {
Type: framework.TypeCommaStringSlice,
Description: `List of OAuth scopes to assign to credentials generated under this role set`,
},
},
ExistenceCheck: b.pathRoleSetExistenceCheck,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.DeleteOperation: b.pathRoleSetDelete,
logical.ReadOperation: b.pathRoleSetRead,
logical.CreateOperation: b.pathRoleSetCreateUpdate,
logical.UpdateOperation: b.pathRoleSetCreateUpdate,
},
HelpSynopsis: pathRoleSetHelpSyn,
HelpDescription: pathRoleSetHelpDesc,
},
// Path to rotate role set service accounts
{
Pattern: fmt.Sprintf("roleset/%s/rotate", framework.GenericNameRegex("name")),
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeString,
Description: "Name of the role.",
},
},
ExistenceCheck: b.pathRoleSetExistenceCheck,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: b.pathRoleSetRotateAccount,
},
HelpSynopsis: pathRoleSetRotateHelpSyn,
HelpDescription: pathRoleSetRotateHelpDesc,
},
// Path to rotating role set service account key used to generate access tokens
{
Pattern: fmt.Sprintf("roleset/%s/rotate-key", framework.GenericNameRegex("name")),
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeString,
Description: "Name of the role.",
},
},
ExistenceCheck: b.pathRoleSetExistenceCheck,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: b.pathRoleSetRotateKey,
},
HelpSynopsis: pathRoleSetRotateKeyHelpSyn,
HelpDescription: pathRoleSetRotateKeyHelpDesc,
},
// Paths for listing role sets
{
Pattern: "rolesets/?",
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ListOperation: b.pathRoleSetList,
},
HelpSynopsis: pathListRoleSetHelpSyn,
HelpDescription: pathListRoleSetHelpDesc,
},
{
Pattern: "roleset/?",
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ListOperation: b.pathRoleSetList,
},
HelpSynopsis: pathListRoleSetHelpSyn,
HelpDescription: pathListRoleSetHelpDesc,
},
}
}
func (b *backend) pathRoleSetExistenceCheck(ctx context.Context, req *logical.Request, d *framework.FieldData) (bool, error) {
nameRaw, ok := d.GetOk("name")
if !ok {
return false, errors.New("roleset name is required")
}
rs, err := getRoleSet(nameRaw.(string), ctx, req.Storage)
if err != nil {
return false, err
}
return rs != nil, nil
}
func (b *backend) pathRoleSetRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
nameRaw, ok := d.GetOk("name")
if !ok {
return logical.ErrorResponse("name is required"), nil
}
rs, err := getRoleSet(nameRaw.(string), ctx, req.Storage)
if err != nil {
return nil, err
}
if rs == nil {
return nil, nil
}
data := map[string]interface{}{
"secret_type": rs.SecretType,
"bindings": rs.Bindings.asOutput(),
}
if rs.AccountId != nil {
data["service_account_email"] = rs.AccountId.EmailOrId
data["service_account_project"] = rs.AccountId.Project
}
if rs.TokenGen != nil && rs.SecretType == SecretTypeAccessToken {
data["token_scopes"] = rs.TokenGen.Scopes
}
return &logical.Response{
Data: data,
}, nil
}
func (b *backend) pathRoleSetDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
nameRaw, ok := d.GetOk("name")
if !ok {
return logical.ErrorResponse("name is required"), nil
}
rsName := nameRaw.(string)
rs, err := getRoleSet(rsName, ctx, req.Storage)
if err != nil {
return nil, errwrap.Wrapf(fmt.Sprintf("unable to get role set %s: {{err}}", rsName), err)
}
if rs == nil {
return nil, nil
}
b.rolesetLock.Lock()
defer b.rolesetLock.Unlock()
if rs.AccountId != nil {
_, err := framework.PutWAL(ctx, req.Storage, walTypeAccount, &walAccount{
RoleSet: rsName,
Id: *rs.AccountId,
})
if err != nil {
return nil, errwrap.Wrapf("unable to create WAL entry to clean up service account: {{err}}", err)
}
for resName, roleSet := range rs.Bindings {
_, err := framework.PutWAL(ctx, req.Storage, walTypeIamPolicy, &walIamPolicy{
RoleSet: rsName,
AccountId: *rs.AccountId,
Resource: resName,
Roles: roleSet.ToSlice(),
})
if err != nil {
return nil, errwrap.Wrapf("unable to create WAL entry to clean up service account bindings: {{err}}", err)
}
}
if rs.TokenGen != nil {
_, err := framework.PutWAL(ctx, req.Storage, walTypeAccount, &walAccountKey{
RoleSet: rsName,
ServiceAccountName: rs.AccountId.ResourceName(),
KeyName: rs.TokenGen.KeyName,
})
if err != nil {
return nil, errwrap.Wrapf("unable to create WAL entry to clean up service account key: {{err}}", err)
}
}
}
if err := req.Storage.Delete(ctx, fmt.Sprintf("roleset/%s", nameRaw)); err != nil {
return nil, err
}
// Clean up resources:
httpC, err := newHttpClient(ctx, req.Storage)
if err != nil {
return nil, err
}
iamAdmin, err := iam.New(httpC)
if err != nil {
return nil, err
}
iamHandle := iamutil.GetIamHandle(httpC, useragent.String())
warnings := make([]string, 0)
if rs.AccountId != nil {
if err := b.deleteServiceAccount(ctx, iamAdmin, rs.AccountId); err != nil {
w := fmt.Sprintf("unable to delete service account '%s' (WAL entry to clean-up later has been added): %v", rs.AccountId.ResourceName(), err)
warnings = append(warnings, w)
}
if err := b.deleteTokenGenKey(ctx, iamAdmin, rs.TokenGen); err != nil {
w := fmt.Sprintf("unable to delete key for service account '%s' (WAL entry to clean-up later has been added): %v", rs.AccountId.ResourceName(), err)
warnings = append(warnings, w)
}
if merr := b.removeBindings(ctx, iamHandle, rs.AccountId.EmailOrId, rs.Bindings); merr != nil {
for _, err := range merr.Errors {
w := fmt.Sprintf("unable to delete IAM policy bindings for service account '%s' (WAL entry to clean-up later has been added): %v", rs.AccountId.EmailOrId, err)
warnings = append(warnings, w)
}
}
}
if len(warnings) > 0 {
return &logical.Response{Warnings: warnings}, nil
}
return nil, nil
}
func (b *backend) pathRoleSetCreateUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
var warnings []string
nameRaw, ok := d.GetOk("name")
if !ok {
return logical.ErrorResponse("name is required"), nil
}
name := nameRaw.(string)
rs, err := getRoleSet(name, ctx, req.Storage)
if err != nil {
return nil, err
}
if rs == nil {
rs = &RoleSet{
Name: name,
}
}
isCreate := req.Operation == logical.CreateOperation
// Secret type
if isCreate {
secretType := d.Get("secret_type").(string)
switch secretType {
case SecretTypeKey, SecretTypeAccessToken:
rs.SecretType = secretType
default:
return logical.ErrorResponse(fmt.Sprintf(`invalid "secret_type" value: "%s"`, secretType)), nil
}
} else {
secretTypeRaw, ok := d.GetOk("secret_type")
if ok && rs.SecretType != secretTypeRaw.(string) {
return logical.ErrorResponse("cannot change secret_type after roleset creation"), nil
}
}
// Project
var project string
projectRaw, ok := d.GetOk("project")
if ok {
project = projectRaw.(string)
if !isCreate && rs.AccountId.Project != project {
return logical.ErrorResponse(fmt.Sprintf("cannot change project for existing role set (old: %s, new: %s)", rs.AccountId.Project, project)), nil
}
if len(project) == 0 {
return logical.ErrorResponse("given empty project"), nil
}
} else {
if isCreate {
return logical.ErrorResponse("project argument is required for new role set"), nil
}
project = rs.AccountId.Project
}
// Default scopes
var scopes []string
scopesRaw, ok := d.GetOk("token_scopes")
if ok {
if rs.SecretType != SecretTypeAccessToken {
warnings = []string{
fmt.Sprintf("ignoring token_scopes, only valid for '%s' secret type role set", SecretTypeAccessToken),
}
}
scopes = scopesRaw.([]string)
if len(scopes) == 0 {
return logical.ErrorResponse("cannot provide empty token_scopes"), nil
}
} else if rs.SecretType == SecretTypeAccessToken {
if isCreate {
return logical.ErrorResponse("token_scopes must be provided for creating access token role set"), nil
}
if rs.TokenGen != nil {
scopes = rs.TokenGen.Scopes
}
}
// Bindings
bRaw, newBindings := d.GetOk("bindings")
if len(bRaw.(string)) == 0 {
return logical.ErrorResponse("given empty bindings string"), nil
}
if isCreate && newBindings == false {
return logical.ErrorResponse("bindings are required for new role set"), nil
}
if !newBindings {
// Just save role with updated metadata:
if err := rs.save(ctx, req.Storage); err != nil {
return logical.ErrorResponse(err.Error()), nil
}
return nil, nil
}
// If new bindings, update service account.
var bindings ResourceBindings
bindings, err = util.ParseBindings(bRaw.(string))
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("unable to parse bindings: %v", err)), nil
}
if len(bindings) == 0 {
return logical.ErrorResponse("unable to parse any bindings from given bindings HCL"), nil
}
rs.RawBindings = bRaw.(string)
updateWarns, err := b.saveRoleSetWithNewAccount(ctx, req.Storage, rs, project, bindings, scopes)
if updateWarns != nil {
warnings = append(warnings, updateWarns...)
}
if err != nil {
return logical.ErrorResponse(err.Error()), nil
} else if warnings != nil && len(warnings) > 0 {
return &logical.Response{Warnings: warnings}, nil
}
return nil, nil
}
func (b *backend) pathRoleSetList(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
rolesets, err := req.Storage.List(ctx, "roleset/")
if err != nil {
return nil, err
}
return logical.ListResponse(rolesets), nil
}
func (b *backend) pathRoleSetRotateAccount(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
nameRaw, ok := d.GetOk("name")
if !ok {
return logical.ErrorResponse("name is required"), nil
}
name := nameRaw.(string)
rs, err := getRoleSet(name, ctx, req.Storage)
if err != nil {
return nil, err
}
if rs == nil {
return logical.ErrorResponse(fmt.Sprintf("roleset '%s' not found", name)), nil
}
var scopes []string
if rs.TokenGen != nil {
scopes = rs.TokenGen.Scopes
}
warnings, err := b.saveRoleSetWithNewAccount(ctx, req.Storage, rs, rs.AccountId.Project, nil, scopes)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
} else if warnings != nil && len(warnings) > 0 {
return &logical.Response{Warnings: warnings}, nil
}
return nil, nil
}
func (b *backend) pathRoleSetRotateKey(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
nameRaw, ok := d.GetOk("name")
if !ok {
return logical.ErrorResponse("name is required"), nil
}
name := nameRaw.(string)
rs, err := getRoleSet(name, ctx, req.Storage)
if err != nil {
return nil, err
}
if rs == nil {
return logical.ErrorResponse(fmt.Sprintf("roleset '%s' not found", name)), nil
}
if rs.SecretType != SecretTypeAccessToken {
return logical.ErrorResponse("cannot rotate key for non-access-token role set"), nil
}
var scopes []string
if rs.TokenGen != nil {
scopes = rs.TokenGen.Scopes
}
warn, err := b.saveRoleSetWithNewTokenKey(ctx, req.Storage, rs, scopes)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}
if warn != "" {
return &logical.Response{Warnings: []string{warn}}, nil
}
return nil, nil
}
func getRoleSet(name string, ctx context.Context, s logical.Storage) (*RoleSet, error) {
entry, err := s.Get(ctx, fmt.Sprintf("%s/%s", rolesetStoragePrefix, name))
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
rs := &RoleSet{}
if err := entry.DecodeJSON(rs); err != nil {
return nil, err
}
return rs, nil
}
const pathRoleSetHelpSyn = `Read/write sets of IAM roles to be given to generated credentials for specified GCP resources.`
const pathListRoleSetHelpSyn = `List existing rolesets.`
const pathRoleSetRotateHelpSyn = `Rotate the service account (and key for access token roleset) created and used to generate secrets`
const pathRoleSetRotateKeyHelpSyn = `Rotate only the service account key used by an access token roleset to generate tokens`
const pathRoleSetRotateHelpDesc = `
This path allows you to rotate (i.e. recreate) the service account used to
generate secrets for a given role set.`
const pathRoleSetRotateKeyHelpDesc = `
This path allows you to rotate (i.e. recreate) the service account
key used to generate access tokens under a given role set. This
path only applies to role sets that generate access tokens `
const pathRoleSetHelpDesc = `
This path allows you create role sets, which bind sets of IAM roles
to specific GCP resources. Secrets (either service account keys or
access tokens) are generated under a role set and will have the
given set of roles on resources.
The specified binding file accepts an HCL (or JSON) string
with the following format:
resource "some/gcp/resource/uri" {
roles = [
"roles/role1",
"roles/role2",
"roles/role3",
...
]
}
The given resource can have the following
* Project-level self link
Self-link for a resource under a given project
(i.e. resource name starts with 'projects/...')
Use if you need to provide a versioned object or
are directly using resource.self_link.
Example (Compute instance):
http://www.googleapis.com/compute/v1/projects/$PROJECT/zones/$ZONE/instances/$INSTANCE_NAME
* Full Resource Name
A scheme-less URI consisting of a DNS-compatible
API service name and a resource path (i.e. the
relative resource name). Useful if you need to
specify what service this resource is under
but just want the preferred supported API version.
Note that if the resource you are using is for
a non-preferred API with multiple service versions,
you MUST specify the version.
Example (IAM service account):
//$SERVICE.googleapis.com/projects/my-project/serviceAccounts/myserviceaccount@...
* Relative Resource Name:
A URI path (path-noscheme) without the leading "/".
It identifies a resource within the API service.
Use if there is only one service that your
resource could belong to. If there are multiple
API versions that support the resource, we will
attempt to use the preferred version and ask
for more specific format otherwise.
Example (Pubsub subscription):
projects/myproject/subscriptions/mysub
`
const pathListRoleSetHelpDesc = `List role sets by role set name`

View File

@@ -0,0 +1,412 @@
package gcpsecrets
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"github.com/hashicorp/errwrap"
"github.com/hashicorp/go-gcp-common/gcputil"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/vault-plugin-secrets-gcp/plugin/iamutil"
"github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util"
"github.com/hashicorp/vault/helper/useragent"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
"google.golang.org/api/iam/v1"
"time"
)
const (
serviceAccountMaxLen = 30
serviceAccountDisplayNameTmpl = "Service account for Vault secrets backend role set %s"
defaultCloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
)
type RoleSet struct {
Name string
SecretType string
RawBindings string
Bindings ResourceBindings
AccountId *gcputil.ServiceAccountId
TokenGen *TokenGenerator
}
func (rs *RoleSet) validate() error {
var err *multierror.Error
if rs.Name == "" {
err = multierror.Append(err, errors.New("role set name is empty"))
}
if rs.SecretType == "" {
err = multierror.Append(err, errors.New("role set secret type is empty"))
}
if rs.AccountId == nil {
err = multierror.Append(err, fmt.Errorf("role set should have account associated"))
}
if len(rs.Bindings) == 0 {
err = multierror.Append(err, fmt.Errorf("role set bindings cannot be empty"))
}
if len(rs.RawBindings) == 0 {
err = multierror.Append(err, fmt.Errorf("role set raw bindings cannot be empty string"))
}
switch rs.SecretType {
case SecretTypeAccessToken:
if rs.TokenGen == nil {
err = multierror.Append(err, fmt.Errorf("access token role set should have initialized token generator"))
} else if len(rs.TokenGen.Scopes) == 0 {
err = multierror.Append(err, fmt.Errorf("access token role set should have defined scopes"))
}
case SecretTypeKey:
break
default:
err = multierror.Append(err, fmt.Errorf("unknown secret type: %s", rs.SecretType))
}
return err.ErrorOrNil()
}
func (rs *RoleSet) save(ctx context.Context, s logical.Storage) error {
if err := rs.validate(); err != nil {
return err
}
entry, err := logical.StorageEntryJSON(fmt.Sprintf("%s/%s", rolesetStoragePrefix, rs.Name), rs)
if err != nil {
return err
}
return s.Put(ctx, entry)
}
func (rs *RoleSet) bindingHash() string {
ssum := sha256.Sum256([]byte(rs.RawBindings)[:])
return base64.StdEncoding.EncodeToString(ssum[:])
}
func (rs *RoleSet) getServiceAccount(iamAdmin *iam.Service) (*iam.ServiceAccount, error) {
if rs.AccountId == nil {
return nil, fmt.Errorf("role set '%s' is invalid, has no associated service account", rs.Name)
}
account, err := iamAdmin.Projects.ServiceAccounts.Get(rs.AccountId.ResourceName()).Do()
if err != nil {
return nil, fmt.Errorf("could not find service account: %v. If account was deleted, role set must be updated (write to roleset/%s/rotate) before generating new secrets", err, rs.Name)
} else if account == nil {
return nil, fmt.Errorf("roleset service account was removed - role set must be updated (path roleset/%s/rotate) before generating new secrets", rs.Name)
}
return account, nil
}
type ResourceBindings map[string]util.StringSet
func (rb ResourceBindings) asOutput() map[string][]string {
out := make(map[string][]string)
for k, v := range rb {
out[k] = v.ToSlice()
}
return out
}
type TokenGenerator struct {
KeyName string
B64KeyJSON string
Scopes []string
}
func (b *backend) saveRoleSetWithNewAccount(ctx context.Context, s logical.Storage, rs *RoleSet, project string, newBinds ResourceBindings, scopes []string) (warning []string, err error) {
b.rolesetLock.Lock()
defer b.rolesetLock.Unlock()
httpC, err := newHttpClient(ctx, s, defaultCloudPlatformScope)
if err != nil {
return nil, err
}
iamAdmin, err := newIamAdmin(ctx, s)
if err != nil {
return nil, err
}
iamHandle := iamutil.GetIamHandle(httpC, useragent.String())
oldAccount := rs.AccountId
oldBindings := rs.Bindings
oldTokenKey := rs.TokenGen
oldWals, err := rs.addWALsForCurrentAccount(ctx, s)
if err != nil {
tryDeleteWALs(ctx, s, oldWals...)
return nil, errwrap.Wrapf("failed to create WAL for cleaning up old account: {{err}}", err)
}
newWals := make([]string, 0, len(newBinds)+2)
walId, err := rs.newServiceAccount(ctx, s, iamAdmin, project)
if err != nil {
tryDeleteWALs(ctx, s, oldWals...)
return nil, err
}
newWals = append(newWals, walId)
binds := rs.Bindings
if newBinds != nil {
binds = newBinds
rs.Bindings = newBinds
}
walIds, err := rs.updateIamPolicies(ctx, s, b.enabledIamResources, iamHandle, binds)
if err != nil {
tryDeleteWALs(ctx, s, oldWals...)
return nil, err
}
newWals = append(newWals, walIds...)
if rs.SecretType == SecretTypeAccessToken {
walId, err := rs.newKeyForTokenGen(ctx, s, iamAdmin, scopes)
if err != nil {
tryDeleteWALs(ctx, s, oldWals...)
return nil, err
}
newWals = append(newWals, walId)
}
if err := rs.save(ctx, s); err != nil {
tryDeleteWALs(ctx, s, oldWals...)
return nil, err
}
// Delete WALs for cleaning up new resources now that they have been saved.
tryDeleteWALs(ctx, s, newWals...)
// Try deleting old resources (WALs exist so we can ignore failures)
if oldAccount == nil || oldAccount.EmailOrId == "" {
// nothing to clean up
return nil, nil
}
// Return any errors as warnings so user knows immediate cleanup failed
warnings := make([]string, 0)
if errs := b.removeBindings(ctx, iamHandle, oldAccount.EmailOrId, oldBindings); errs != nil {
warnings = make([]string, len(errs.Errors), len(errs.Errors)+2)
for idx, err := range errs.Errors {
warnings[idx] = fmt.Sprintf("unable to immediately delete old binding (WAL cleanup entry has been added): %v", err)
}
}
if err := b.deleteServiceAccount(ctx, iamAdmin, oldAccount); err != nil {
warnings = append(warnings, fmt.Sprintf("unable to immediately delete old account (WAL cleanup entry has been added): %v", err))
}
if err := b.deleteTokenGenKey(ctx, iamAdmin, oldTokenKey); err != nil {
warnings = append(warnings, fmt.Sprintf("unable to immediately delete old key (WAL cleanup entry has been added): %v", err))
}
return warnings, nil
}
func (b *backend) saveRoleSetWithNewTokenKey(ctx context.Context, s logical.Storage, rs *RoleSet, scopes []string) (warning string, err error) {
b.rolesetLock.Lock()
defer b.rolesetLock.Unlock()
if rs.SecretType != SecretTypeAccessToken {
return "", fmt.Errorf("a key is not saved or used for non-access-token role set '%s'", rs.Name)
}
iamAdmin, err := newIamAdmin(ctx, s)
if err != nil {
return "", err
}
oldKeyWalId := ""
if rs.TokenGen != nil {
if oldKeyWalId, err = framework.PutWAL(ctx, s, walTypeAccountKey, &walAccountKey{
RoleSet: rs.Name,
KeyName: rs.TokenGen.KeyName,
ServiceAccountName: rs.AccountId.ResourceName(),
}); err != nil {
return "", errwrap.Wrapf("unable to create WAL for deleting old key: {{err}}", err)
}
}
oldKeyGen := rs.TokenGen
newKeyWalId, err := rs.newKeyForTokenGen(ctx, s, iamAdmin, scopes)
if err != nil {
tryDeleteWALs(ctx, s, oldKeyWalId)
return "", err
}
if err := rs.save(ctx, s); err != nil {
tryDeleteWALs(ctx, s, oldKeyWalId)
return "", err
}
// Delete WALs for cleaning up new key now that it's been saved.
tryDeleteWALs(ctx, s, newKeyWalId)
if err := b.deleteTokenGenKey(ctx, iamAdmin, oldKeyGen); err != nil {
return errwrap.Wrapf("unable to delete old key (delayed cleaned up WAL entry added): {{err}}", err).Error(), nil
}
return "", nil
}
func (rs *RoleSet) addWALsForCurrentAccount(ctx context.Context, s logical.Storage) ([]string, error) {
if rs.AccountId == nil {
return nil, nil
}
wals := make([]string, 0, len(rs.Bindings)+2)
walId, err := framework.PutWAL(ctx, s, walTypeAccount, &walAccount{
RoleSet: rs.Name,
Id: gcputil.ServiceAccountId{
Project: rs.AccountId.Project,
EmailOrId: rs.AccountId.EmailOrId,
},
})
if err != nil {
return nil, err
}
wals = append(wals, walId)
for resource, roles := range rs.Bindings {
var walId string
walId, err = framework.PutWAL(ctx, s, walTypeIamPolicy, &walIamPolicy{
RoleSet: rs.Name,
AccountId: gcputil.ServiceAccountId{
Project: rs.AccountId.Project,
EmailOrId: rs.AccountId.EmailOrId,
},
Resource: resource,
Roles: roles.ToSlice(),
})
if err != nil {
return nil, err
}
wals = append(wals, walId)
}
if rs.SecretType == SecretTypeAccessToken && rs.TokenGen != nil {
walId, err := framework.PutWAL(ctx, s, walTypeAccountKey, &walAccountKey{
RoleSet: rs.Name,
KeyName: rs.TokenGen.KeyName,
ServiceAccountName: rs.AccountId.ResourceName(),
})
if err != nil {
return nil, err
}
wals = append(wals, walId)
}
return wals, nil
}
func (rs *RoleSet) newServiceAccount(ctx context.Context, s logical.Storage, iamAdmin *iam.Service, project string) (string, error) {
saEmailPrefix := roleSetServiceAccountName(rs.Name)
projectName := fmt.Sprintf("projects/%s", project)
displayName := fmt.Sprintf(serviceAccountDisplayNameTmpl, rs.Name)
walId, err := framework.PutWAL(ctx, s, walTypeAccount, &walAccount{
RoleSet: rs.Name,
Id: gcputil.ServiceAccountId{
Project: project,
EmailOrId: fmt.Sprintf("%s@%s.iam.gserviceaccount.com", saEmailPrefix, project),
},
})
if err != nil {
return "", errwrap.Wrapf("unable to create WAL entry for generating new service account: {{err}}", err)
}
sa, err := iamAdmin.Projects.ServiceAccounts.Create(
projectName, &iam.CreateServiceAccountRequest{
AccountId: saEmailPrefix,
ServiceAccount: &iam.ServiceAccount{DisplayName: displayName},
}).Do()
if err != nil {
return walId, errwrap.Wrapf(fmt.Sprintf("unable to create new service account under project '%s': {{err}}", projectName), err)
}
rs.AccountId = &gcputil.ServiceAccountId{
Project: project,
EmailOrId: sa.Email,
}
return walId, nil
}
func (rs *RoleSet) newKeyForTokenGen(ctx context.Context, s logical.Storage, iamAdmin *iam.Service, scopes []string) (string, error) {
walId, err := framework.PutWAL(ctx, s, walTypeAccountKey, &walAccountKey{
RoleSet: rs.Name,
KeyName: "",
ServiceAccountName: rs.AccountId.ResourceName(),
})
if err != nil {
return "", err
}
key, err := iamAdmin.Projects.ServiceAccounts.Keys.Create(rs.AccountId.ResourceName(),
&iam.CreateServiceAccountKeyRequest{
PrivateKeyType: privateKeyTypeJson,
}).Do()
if err != nil {
framework.DeleteWAL(ctx, s, walId)
return "", err
}
rs.TokenGen = &TokenGenerator{
KeyName: key.Name,
B64KeyJSON: key.PrivateKeyData,
Scopes: scopes,
}
return walId, nil
}
func (rs *RoleSet) updateIamPolicies(ctx context.Context, s logical.Storage, enabledIamResources iamutil.EnabledResources, iamHandle *iamutil.IamHandle, rb ResourceBindings) ([]string, error) {
wals := make([]string, 0, len(rb))
for rName, roles := range rb {
walId, err := framework.PutWAL(ctx, s, walTypeIamPolicy, &walIamPolicy{
RoleSet: rs.Name,
AccountId: gcputil.ServiceAccountId{
Project: rs.AccountId.Project,
EmailOrId: rs.AccountId.EmailOrId,
},
Resource: rName,
Roles: roles.ToSlice(),
})
if err != nil {
return wals, err
}
resource, err := enabledIamResources.Resource(rName)
if err != nil {
return wals, err
}
p, err := iamHandle.GetIamPolicy(ctx, resource)
if err != nil {
return wals, err
}
changed, newP := p.AddBindings(&iamutil.PolicyDelta{
Roles: roles,
Email: rs.AccountId.EmailOrId,
})
if !changed || newP == nil {
continue
}
if _, err := iamHandle.SetIamPolicy(ctx, resource, newP); err != nil {
return wals, err
}
wals = append(wals, walId)
}
return wals, nil
}
func roleSetServiceAccountName(rsName string) (name string) {
intSuffix := fmt.Sprintf("%d", time.Now().Unix())
fullName := fmt.Sprintf("vault%s-%s", rsName, intSuffix)
name = fullName
if len(fullName) > serviceAccountMaxLen {
toTrunc := len(fullName) - serviceAccountMaxLen
name = fmt.Sprintf("vault%s-%s", rsName[:len(rsName)-toTrunc], intSuffix)
}
return name
}

View File

@@ -0,0 +1,276 @@
package gcpsecrets
import (
"context"
"fmt"
"github.com/hashicorp/errwrap"
"github.com/hashicorp/go-gcp-common/gcputil"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/vault-plugin-secrets-gcp/plugin/iamutil"
"github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util"
"github.com/hashicorp/vault/helper/useragent"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
"github.com/mitchellh/mapstructure"
"google.golang.org/api/googleapi"
"google.golang.org/api/iam/v1"
)
const (
walTypeAccount = "account"
walTypeAccountKey = "account_key"
walTypeIamPolicy = "iam_policy"
)
func (b *backend) walRollback(ctx context.Context, req *logical.Request, kind string, data interface{}) error {
switch kind {
case walTypeAccount:
return b.serviceAccountRollback(ctx, req, data)
case walTypeAccountKey:
return b.serviceAccountKeyRollback(ctx, req, data)
case walTypeIamPolicy:
return b.serviceAccountPolicyRollback(ctx, req, data)
default:
return fmt.Errorf("unknown type to rollback")
}
}
type walAccount struct {
RoleSet string
Id gcputil.ServiceAccountId
}
type walAccountKey struct {
RoleSet string
ServiceAccountName string
KeyName string
}
type walIamPolicy struct {
RoleSet string
AccountId gcputil.ServiceAccountId
Resource string
Roles []string
}
func (b *backend) serviceAccountRollback(ctx context.Context, req *logical.Request, data interface{}) error {
b.rolesetLock.Lock()
defer b.rolesetLock.Unlock()
var entry walAccount
if err := mapstructure.Decode(data, &entry); err != nil {
return err
}
// If account is still being used, WAL entry was not
// deleted properly after a successful operation.
// Remove WAL entry.
rs, err := getRoleSet(entry.RoleSet, ctx, req.Storage)
if err != nil {
return err
}
if rs != nil && entry.Id.ResourceName() == rs.AccountId.ResourceName() {
// Still being used - don't delete this service account.
return nil
}
// Delete service account.
iamC, err := newIamAdmin(ctx, req.Storage)
if err != nil {
return err
}
return b.deleteServiceAccount(ctx, iamC, &entry.Id)
}
func (b *backend) serviceAccountKeyRollback(ctx context.Context, req *logical.Request, data interface{}) error {
b.rolesetLock.Lock()
defer b.rolesetLock.Unlock()
var entry walAccountKey
if err := mapstructure.Decode(data, &entry); err != nil {
return err
}
// If key is still being used, WAL entry was not
// deleted properly after a successful operation.
// Remove WAL entry.
rs, err := getRoleSet(entry.RoleSet, ctx, req.Storage)
if err != nil {
return err
}
if rs != nil && rs.TokenGen != nil && entry.KeyName == rs.TokenGen.KeyName {
return nil
}
iamC, err := newIamAdmin(ctx, req.Storage)
if err != nil {
return err
}
if entry.KeyName == "" {
if rs.SecretType != SecretTypeAccessToken {
// Do not clean up non-access-token role set keys.
return nil
}
// delete all keys not in use by role set
keys, err := iamC.Projects.ServiceAccounts.Keys.List(rs.AccountId.ResourceName()).KeyTypes("USER_MANAGED").Do()
if err != nil && !isGoogleApi404Error(err) {
return err
} else if err != nil || keys == nil {
return nil
}
for _, k := range keys.Keys {
if rs.TokenGen != nil && rs.TokenGen.KeyName == k.Name {
continue
}
// Delete all keys not being used by role set
_, err = iamC.Projects.ServiceAccounts.Keys.Delete(entry.KeyName).Do()
if err != nil && !isGoogleApi404Error(err) {
return err
}
}
} else {
_, err = iamC.Projects.ServiceAccounts.Keys.Delete(entry.KeyName).Do()
if err != nil && !isGoogleApi404Error(err) {
return err
}
}
return nil
}
func (b *backend) serviceAccountPolicyRollback(ctx context.Context, req *logical.Request, data interface{}) error {
b.rolesetLock.Lock()
defer b.rolesetLock.Unlock()
var entry walIamPolicy
if err := mapstructure.Decode(data, &entry); err != nil {
return err
}
// Try to verify service account not being used by roleset
rs, err := getRoleSet(entry.RoleSet, ctx, req.Storage)
if err != nil {
return err
}
// Take our any bindings still being used by this role set from roles being removed.
rolesToRemove := util.ToSet(entry.Roles)
if rs.AccountId.ResourceName() == entry.AccountId.ResourceName() {
currRoles, ok := rs.Bindings[entry.Resource]
if ok {
rolesToRemove = rolesToRemove.Sub(currRoles)
}
}
r, err := b.enabledIamResources.Resource(entry.Resource)
if err != nil {
return err
}
httpC, err := newHttpClient(ctx, req.Storage)
if err != nil {
return err
}
iamHandle := iamutil.GetIamHandle(httpC, useragent.String())
if err != nil {
return err
}
p, err := iamHandle.GetIamPolicy(ctx, r)
if err != nil {
return err
}
changed, newP := p.RemoveBindings(
&iamutil.PolicyDelta{
Email: entry.AccountId.EmailOrId,
Roles: rolesToRemove,
})
if !changed {
return nil
}
_, err = iamHandle.SetIamPolicy(ctx, r, newP)
return err
}
func (b *backend) deleteServiceAccount(ctx context.Context, iamAdmin *iam.Service, account *gcputil.ServiceAccountId) error {
if account == nil || account.EmailOrId == "" {
return nil
}
_, err := iamAdmin.Projects.ServiceAccounts.Delete(account.ResourceName()).Do()
if err != nil && !isGoogleApi404Error(err) {
return errwrap.Wrapf("unable to delete service account: {{err}}", err)
}
return nil
}
func (b *backend) deleteTokenGenKey(ctx context.Context, iamAdmin *iam.Service, tgen *TokenGenerator) error {
if tgen == nil || tgen.KeyName == "" {
return nil
}
_, err := iamAdmin.Projects.ServiceAccounts.Keys.Delete(tgen.KeyName).Do()
if err != nil && !isGoogleApi404Error(err) {
return errwrap.Wrapf("unable to delete service account key: {{err}}", err)
}
return nil
}
func (b *backend) removeBindings(ctx context.Context, iamHandle *iamutil.IamHandle, email string, bindings ResourceBindings) (allErr *multierror.Error) {
for resName, roles := range bindings {
resource, err := b.enabledIamResources.Resource(resName)
if err != nil {
allErr = multierror.Append(allErr, errwrap.Wrapf(fmt.Sprintf("unable to delete role binding for resource '%s': {{err}}", resName), err))
continue
}
p, err := iamHandle.GetIamPolicy(ctx, resource)
if err != nil {
allErr = multierror.Append(allErr, errwrap.Wrapf(fmt.Sprintf("unable to delete role binding for resource '%s': {{err}}", resName), err))
continue
}
changed, newP := p.RemoveBindings(&iamutil.PolicyDelta{
Email: email,
Roles: roles,
})
if !changed {
continue
}
if _, err = iamHandle.SetIamPolicy(ctx, resource, newP); err != nil {
allErr = multierror.Append(allErr, errwrap.Wrapf(fmt.Sprintf("unable to delete role binding for resource '%s': {{err}}", resName), err))
continue
}
}
return
}
// This tries to clean up WALs that are no longer needed.
// We can ignore errors if deletion fails as WAL rollback
// will not be done if the object is still in use in the roleset
// or was not actually created.
func tryDeleteWALs(ctx context.Context, s logical.Storage, walIds ...string) {
for _, walId := range walIds {
// ignore errors - if not deleted and still used by
// roleset, will be ignored
framework.DeleteWAL(ctx, s, walId)
}
}
func isGoogleApi404Error(err error) bool {
if err == nil {
return false
}
gErr, ok := err.(*googleapi.Error)
if ok && gErr.Code == 404 {
return true
}
return false
}

View File

@@ -0,0 +1,162 @@
package gcpsecrets
import (
"context"
"encoding/base64"
"errors"
"fmt"
"github.com/hashicorp/errwrap"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/googleapi"
"google.golang.org/api/iam/v1"
"net/http"
"net/url"
"time"
)
const (
SecretTypeAccessToken = "access_token"
revokeAccessTokenEndpoint = "https://accounts.google.com/o/oauth2/revoke"
revokeTokenWarning = `revocation request was successful; however, due to how OAuth access propagation works, the OAuth token might still be valid until it expires`
)
func secretAccessToken(b *backend) *framework.Secret {
return &framework.Secret{
Type: SecretTypeAccessToken,
Fields: map[string]*framework.FieldSchema{
"token": {
Type: framework.TypeString,
Description: "OAuth2 token",
},
},
Renew: b.secretAccessTokenRenew,
Revoke: secretAccessTokenRevoke,
}
}
func pathSecretAccessToken(b *backend) *framework.Path {
return &framework.Path{
Pattern: fmt.Sprintf("token/%s", framework.GenericNameRegex("roleset")),
Fields: map[string]*framework.FieldSchema{
"roleset": {
Type: framework.TypeString,
Description: "Required. Name of the role set.",
},
},
ExistenceCheck: b.pathRoleSetExistenceCheck,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathAccessToken,
logical.UpdateOperation: b.pathAccessToken,
},
HelpSynopsis: pathTokenHelpSyn,
HelpDescription: pathTokenHelpDesc,
}
}
func (b *backend) pathAccessToken(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
rsName := d.Get("roleset").(string)
rs, err := getRoleSet(rsName, ctx, req.Storage)
if err != nil {
return nil, err
}
if rs == nil {
return logical.ErrorResponse(fmt.Sprintf("role set '%s' does not exists", rsName)), nil
}
if rs.SecretType != SecretTypeAccessToken {
return logical.ErrorResponse(fmt.Sprintf("role set '%s' cannot generate access tokens (has secret type %s)", rsName, rs.SecretType)), nil
}
return b.getSecretAccessToken(ctx, req.Storage, rs)
}
func (b *backend) secretAccessTokenRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
// Renewal not allowed
return logical.ErrorResponse("short-term access tokens cannot be renewed - request new access token instead"), nil
}
func secretAccessTokenRevoke(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
tokenRaw, ok := req.Secret.InternalData["access_token"]
if !ok {
return nil, fmt.Errorf("secret is missing token internal data")
}
resp, err := http.Get(revokeAccessTokenEndpoint + fmt.Sprintf("?token=%s", url.QueryEscape(tokenRaw.(string))))
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("revoke returned error: %v", err)), nil
}
if err := googleapi.CheckResponse(resp); err != nil {
return logical.ErrorResponse(err.Error()), nil
}
return &logical.Response{
Warnings: []string{revokeTokenWarning},
}, nil
}
func (b *backend) getSecretAccessToken(ctx context.Context, s logical.Storage, rs *RoleSet) (*logical.Response, error) {
iamC, err := newIamAdmin(ctx, s)
if err != nil {
return nil, errwrap.Wrapf("could not create IAM Admin client: {{err}}", err)
}
// Verify account still exists
_, err = rs.getServiceAccount(iamC)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("could not get role set service account: %v", err)), nil
}
if rs.TokenGen == nil || rs.TokenGen.KeyName == "" {
return logical.ErrorResponse(fmt.Sprintf("invalid role set has no service account key, must be updated (path roleset/%s/rotate-key) before generating new secrets", rs.Name)), nil
}
token, err := rs.TokenGen.getAccessToken(ctx, iamC)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("could not generate token: %v", err)), nil
}
secretD := map[string]interface{}{
"token": token.AccessToken,
}
internalD := map[string]interface{}{
"access_token": token.AccessToken,
"key_name": rs.TokenGen.KeyName,
"role_set": rs.Name,
"role_set_bindings": rs.bindingHash(),
}
resp := b.Secret(SecretTypeAccessToken).Response(secretD, internalD)
resp.Secret.LeaseOptions.TTL = token.Expiry.Sub(time.Now())
resp.Secret.LeaseOptions.Renewable = false
return resp, err
}
func (tg *TokenGenerator) getAccessToken(ctx context.Context, iamAdmin *iam.Service) (*oauth2.Token, error) {
key, err := iamAdmin.Projects.ServiceAccounts.Keys.Get(tg.KeyName).Do()
if err != nil {
return nil, errwrap.Wrapf("could not verify key used to generate tokens: {{err}}", err)
}
if key == nil {
return nil, errors.New("could not find key used to generate tokens, must update role set")
}
jsonBytes, err := base64.StdEncoding.DecodeString(tg.B64KeyJSON)
if err != nil {
return nil, errwrap.Wrapf("could not b64-decode key data: {{err}}", err)
}
cfg, err := google.JWTConfigFromJSON(jsonBytes, tg.Scopes...)
if err != nil {
return nil, errwrap.Wrapf("could not generate token JWT config: {{err}}", err)
}
tkn, err := cfg.TokenSource(ctx).Token()
if err != nil {
return nil, errwrap.Wrapf("could not generate token: {{err}}", err)
}
return tkn, err
}

View File

@@ -0,0 +1,245 @@
package gcpsecrets
import (
"context"
"fmt"
"github.com/hashicorp/errwrap"
"github.com/hashicorp/vault/logical"
"github.com/hashicorp/vault/logical/framework"
"google.golang.org/api/iam/v1"
"time"
)
const (
SecretTypeKey = "service_account_key"
keyAlgorithmRSA2k = "KEY_ALG_RSA_2048"
privateKeyTypeJson = "TYPE_GOOGLE_CREDENTIALS_FILE"
)
func secretServiceAccountKey(b *backend) *framework.Secret {
return &framework.Secret{
Type: SecretTypeKey,
Fields: map[string]*framework.FieldSchema{
"private_key_data": {
Type: framework.TypeString,
Description: "Base-64 encoded string. Private key data for a service account key",
},
"key_algorithm": {
Type: framework.TypeString,
Description: "Which type of key and algorithm to use for the key (defaults to 2K RSA). Valid values are GCP enum(ServiceAccountKeyAlgorithm)",
},
"key_type": {
Type: framework.TypeString,
Description: "Type of the private key (i.e. whether it is JSON or P12). Valid values are GCP enum(ServiceAccountPrivateKeyType)",
},
},
Renew: b.secretKeyRenew,
Revoke: secretKeyRevoke,
}
}
func pathSecretServiceAccountKey(b *backend) *framework.Path {
return &framework.Path{
Pattern: fmt.Sprintf("key/%s", framework.GenericNameRegex("roleset")),
Fields: map[string]*framework.FieldSchema{
"roleset": {
Type: framework.TypeString,
Description: "Required. Name of the role set.",
},
"key_algorithm": {
Type: framework.TypeString,
Description: fmt.Sprintf(`Private key algorithm for service account key - defaults to %s"`, keyAlgorithmRSA2k),
Default: keyAlgorithmRSA2k,
},
"key_type": {
Type: framework.TypeString,
Description: fmt.Sprintf(`Private key type for service account key - defaults to %s"`, privateKeyTypeJson),
Default: privateKeyTypeJson,
},
},
ExistenceCheck: b.pathRoleSetExistenceCheck,
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.ReadOperation: b.pathServiceAccountKey,
logical.UpdateOperation: b.pathServiceAccountKey,
},
HelpSynopsis: pathServiceAccountKeySyn,
HelpDescription: pathServiceAccountKeyDesc,
}
}
func (b *backend) pathServiceAccountKey(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
rsName := d.Get("roleset").(string)
keyType := d.Get("key_type").(string)
keyAlg := d.Get("key_algorithm").(string)
rs, err := getRoleSet(rsName, ctx, req.Storage)
if err != nil {
return nil, err
}
if rs == nil {
return logical.ErrorResponse(fmt.Sprintf("role set '%s' does not exists", rsName)), nil
}
if rs.SecretType != SecretTypeKey {
return logical.ErrorResponse(fmt.Sprintf("role set '%s' cannot generate service account keys (has secret type %s)", rsName, rs.SecretType)), nil
}
return b.getSecretKey(ctx, req.Storage, rs, keyType, keyAlg)
}
func (b *backend) secretKeyRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
resp, err := b.verifySecretServiceKeyExists(ctx, req)
if err != nil || resp != nil {
return resp, err
}
cfg, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if cfg == nil {
cfg = &config{}
}
f := framework.LeaseExtend(cfg.TTL, cfg.MaxTTL, b.System())
return f(ctx, req, d)
}
func (b *backend) verifySecretServiceKeyExists(ctx context.Context, req *logical.Request) (*logical.Response, error) {
keyName, ok := req.Secret.InternalData["key_name"]
if !ok {
return nil, fmt.Errorf("invalid secret, internal data is missing key name")
}
rsName, ok := req.Secret.InternalData["role_set"]
if !ok {
return nil, fmt.Errorf("invalid secret, internal data is missing role set name")
}
bindingSum, ok := req.Secret.InternalData["role_set_bindings"]
if !ok {
return nil, fmt.Errorf("invalid secret, internal data is missing role set checksum")
}
// Verify role set was not deleted.
rs, err := getRoleSet(rsName.(string), ctx, req.Storage)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("could not find role set '%v' for secret", rsName)), nil
}
// Verify role set bindings have not changed since secret was generated.
if rs.bindingHash() != bindingSum.(string) {
return logical.ErrorResponse(fmt.Sprintf("role set '%v' bindings were updated since secret was generated, cannot renew", rsName)), nil
}
// Verify service account key still exists.
iamAdmin, err := newIamAdmin(ctx, req.Storage)
if err != nil {
return logical.ErrorResponse("could not confirm key still exists in GCP"), nil
}
if k, err := iamAdmin.Projects.ServiceAccounts.Keys.Get(keyName.(string)).Do(); err != nil || k == nil {
return logical.ErrorResponse(fmt.Sprintf("could not confirm key still exists in GCP: %v", err)), nil
}
return nil, nil
}
func secretKeyRevoke(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
keyNameRaw, ok := req.Secret.InternalData["key_name"]
if !ok {
return nil, fmt.Errorf("secret is missing key_name internal data")
}
iamAdmin, err := newIamAdmin(ctx, req.Storage)
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}
_, err = iamAdmin.Projects.ServiceAccounts.Keys.Delete(keyNameRaw.(string)).Do()
if err != nil && !isGoogleApi404Error(err) {
return logical.ErrorResponse(fmt.Sprintf("unable to delete service account key: %v", err)), nil
}
return nil, nil
}
func (b *backend) getSecretKey(ctx context.Context, s logical.Storage, rs *RoleSet, keyType, keyAlgorithm string) (*logical.Response, error) {
var ttl time.Duration
cfg, err := getConfig(ctx, s)
if err != nil {
return nil, errwrap.Wrapf("could not read backend config: {{err}}", err)
}
max := b.System().MaxLeaseTTL()
if cfg == nil {
ttl = b.System().DefaultLeaseTTL()
} else {
if cfg.MaxTTL != 0 && cfg.MaxTTL < max {
max = cfg.MaxTTL
}
if cfg.TTL > 0 {
ttl = cfg.TTL
}
}
if ttl > max {
ttl = max
}
iamC, err := newIamAdmin(ctx, s)
if err != nil {
return nil, errwrap.Wrapf("could not create IAM Admin client: {{err}}", err)
}
account, err := rs.getServiceAccount(iamC)
if err != nil {
return logical.ErrorResponse(fmt.Sprintf("roleset service account was removed - role set must be updated (write to roleset/%s/rotate) before generating new secrets", rs.Name)), nil
}
key, err := iamC.Projects.ServiceAccounts.Keys.Create(
account.Name, &iam.CreateServiceAccountKeyRequest{
KeyAlgorithm: keyAlgorithm,
PrivateKeyType: keyType,
}).Do()
if err != nil {
return logical.ErrorResponse(err.Error()), nil
}
secretD := map[string]interface{}{
"private_key_data": key.PrivateKeyData,
"key_algorithm": key.KeyAlgorithm,
"key_type": key.PrivateKeyType,
}
internalD := map[string]interface{}{
"key_name": key.Name,
"role_set": rs.Name,
"role_set_bindings": rs.bindingHash(),
}
resp := b.Secret(SecretTypeKey).Response(secretD, internalD)
resp.Secret.LeaseOptions.TTL = ttl
resp.Secret.LeaseOptions.Renewable = true
return resp, nil
}
const pathTokenHelpSyn = `Generate an OAuth2 access token under a specific role set.`
const pathTokenHelpDesc = `
This path will generate a new OAuth2 access token for accessing GCP APIs.
A role set, binding IAM roles to specific GCP resources, will be specified
by name - for example, if this backend is mounted at "gcp",
then "gcp/token/deploy" would generate tokens for the "deploy" role set.
On the backend, each roleset is associated with a service account.
The token will be associated with this service account. Tokens have a
short-term lease (1-hour) associated with them but cannot be renewed.
`
const pathServiceAccountKeySyn = `Generate an service account private key under a specific role set.`
const pathServiceAccountKeyDesc = `
This path will generate a new service account private key for accessing GCP APIs.
A role set, binding IAM roles to specific GCP resources, will be specified
by name - for example, if this backend is mounted at "gcp", then "gcp/key/deploy"
would generate service account keys for the "deploy" role set.
On the backend, each roleset is associated with a service account under
which secrets/keys are created.
`

View File

@@ -0,0 +1,12 @@
{{define "bindings" -}}
{{ range $resource,$roleStringSet := . -}}
resource "{{$resource}}" {
roles = [
{{- range $role, $v := $roleStringSet -}}
"{{ $role }}",
{{- end -}}
],
}
{{ end -}}
{{- end }}

View File

@@ -0,0 +1,135 @@
package util
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"github.com/hashicorp/errwrap"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast"
"io/ioutil"
"strings"
"text/template"
)
const bindingTemplate = "util/bindings_template"
func BindingsHCL(bindings map[string]StringSet) (string, error) {
tpl, err := template.ParseFiles(bindingTemplate)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, "bindings", bindings); err != nil {
return "", err
}
return buf.String(), nil
}
func ParseBindings(bindingsStr string) (map[string]StringSet, error) {
// Try to base64 decode
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(bindingsStr))
decoded, b64err := ioutil.ReadAll(decoder)
var bindsString string
if b64err != nil {
bindsString = bindingsStr
} else {
bindsString = string(decoded)
}
root, err := hcl.Parse(bindsString)
if err != nil {
if b64err == nil {
return nil, errwrap.Wrapf("unable to parse base64-encoded bindings as valid HCL: {{err}}", err)
} else {
return nil, errwrap.Wrapf("unable to parse raw string bindings as valid HCL: {{err}}", err)
}
}
bindingLst, ok := root.Node.(*ast.ObjectList)
if !ok {
return nil, errors.New("unable to parse bindings: does not contain a root object")
}
bindingsMap, err := parseBindingObjList(bindingLst)
if err != nil {
return nil, errwrap.Wrapf("unable to parse bindings: {{err}}", err)
}
return bindingsMap, nil
}
func parseBindingObjList(topList *ast.ObjectList) (map[string]StringSet, error) {
var merr *multierror.Error
bindings := make(map[string]StringSet)
for _, item := range topList.Items {
if len(item.Keys) != 2 {
merr = multierror.Append(merr, fmt.Errorf("invalid resource item does not have ID on line %d", item.Assign.Line))
continue
}
key := item.Keys[0].Token.Value().(string)
if key != "resource" {
merr = multierror.Append(merr, fmt.Errorf("invalid key '%s' (line %d)", key, item.Assign.Line))
continue
}
resourceName := item.Keys[1].Token.Value().(string)
_, ok := bindings[resourceName]
if !ok {
bindings[resourceName] = make(StringSet)
}
resourceList := item.Val.(*ast.ObjectType).List
for _, rolesItem := range resourceList.Items {
key := rolesItem.Keys[0].Token.Text
switch key {
case "roles":
parseRoles(rolesItem, bindings[resourceName], merr)
default:
merr = multierror.Append(merr, fmt.Errorf("invalid key '%s' in resource '%s' (line %d)", key, resourceName, item.Assign.Line))
continue
}
}
}
err := merr.ErrorOrNil()
if err != nil {
return nil, err
}
return bindings, nil
}
func parseRoles(item *ast.ObjectItem, roleSet StringSet, merr *multierror.Error) {
lst, ok := item.Val.(*ast.ListType)
if !ok {
merr = multierror.Append(merr, fmt.Errorf("roles must be a list (line %d)", item.Assign.Line))
return
}
for _, roleItem := range lst.List {
role := roleItem.(*ast.LiteralType).Token.Value().(string)
tkns := strings.Split(role, "/")
switch len(tkns) {
case 2:
// "roles/X"
if tkns[0] == "roles" {
roleSet.Add(role)
continue
}
case 4:
// "projects/X/roles/Y" or "organizations/X/roles/Y"
if (tkns[0] == "projects" || tkns[0] == "organizations") && tkns[2] == "roles" {
roleSet.Add(role)
continue
}
}
merr = multierror.Append(merr, fmt.Errorf("invalid role: %s (line %d): must be project-level, organization-level, or global role", role, roleItem.Pos().Line))
}
}

View File

@@ -0,0 +1,80 @@
package util
// A set of strings
type StringSet map[string]struct{}
func ToSet(values []string) StringSet {
s := make(StringSet)
for _, v := range values {
s[v] = struct{}{}
}
return s
}
func (ss StringSet) Add(v string) {
ss[v] = struct{}{}
}
func (ss StringSet) ToSlice() []string {
ls := make([]string, len(ss))
i := 0
for r := range ss {
ls[i] = r
i++
}
return ls
}
func (ss StringSet) Includes(v string) bool {
_, ok := ss[v]
return ok
}
func (ss StringSet) Update(members ...string) {
for _, v := range members {
ss[v] = struct{}{}
}
}
func (ss StringSet) Union(other StringSet) StringSet {
un := make(StringSet)
for v := range ss {
un[v] = struct{}{}
}
for v := range other {
un[v] = struct{}{}
}
return un
}
func (ss StringSet) Intersection(other StringSet) StringSet {
inter := make(StringSet)
var s StringSet
if len(ss) > len(other) {
s = other
} else {
s = ss
}
for v := range s {
if other.Includes(v) {
inter[v] = struct{}{}
}
}
return inter
}
func (ss StringSet) Sub(other StringSet) StringSet {
sub := make(StringSet)
for v := range ss {
if !other.Includes(v) {
sub[v] = struct{}{}
}
}
return sub
}
func (ss StringSet) Equals(other StringSet) bool {
return len(ss.Intersection(other)) == len(ss)
}

View File

@@ -0,0 +1,31 @@
package util
import (
"github.com/hashicorp/go-gcp-common/gcputil"
"os"
"testing"
)
const googleCredentialsEnv = "TEST_GOOGLE_CREDENTIALS"
const googleProjectEnv = "TEST_GOOGLE_PROJECT"
func GetTestCredentials(t *testing.T) (string, *gcputil.GcpCredentials) {
credentialsJSON := os.Getenv(googleCredentialsEnv)
if credentialsJSON == "" {
t.Fatalf("%s must be set to JSON string of valid Google credentials file", googleCredentialsEnv)
}
credentials, err := gcputil.Credentials(credentialsJSON)
if err != nil {
t.Fatalf("valid Google credentials JSON could not be read from %s env variable: %v", googleCredentialsEnv, err)
}
return credentialsJSON, credentials
}
func GetTestProject(t *testing.T) string {
project := os.Getenv(googleProjectEnv)
if project == "" {
t.Fatalf("%s must be set to JSON string of valid Google credentials file", googleProjectEnv)
}
return project
}

24
vendor/vendor.json vendored
View File

@@ -1080,6 +1080,12 @@
"revision": "d5fe4b57a186c716b0e00b8c301cbd9b4182694d",
"revisionTime": "2017-12-18T14:54:08Z"
},
{
"checksumSHA1": "mxGkRISfv2EldeYihuciIYrKNjI=",
"path": "github.com/hashicorp/go-gcp-common/gcputil",
"revision": "4b38f46ac60aebc9c67f2548aec1ff5bc1e9a4eb",
"revisionTime": "2018-02-23T19:15:17Z"
},
{
"checksumSHA1": "xTdKLI1gAJOTqswvr15a/fI8ucA=",
"path": "github.com/hashicorp/go-hclog",
@@ -1302,6 +1308,24 @@
"revision": "6c7dd3f219b06c4fa249bff2b859733a2f262e3b",
"revisionTime": "2018-03-20T18:05:36Z"
},
{
"checksumSHA1": "w+bhfXfIBMWNMuH2SJqw6Y5c6mk=",
"path": "github.com/hashicorp/vault-plugin-secrets-gcp/plugin",
"revision": "7633b05ac6d9a8f77f9255ef5aea09f35a145b0b",
"revisionTime": "2018-03-22T03:06:48Z"
},
{
"checksumSHA1": "0bNwTVNqwHHUJ+r1usgdtGsiSJs=",
"path": "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/iamutil",
"revision": "69b9785fc3a7b4e7ca40c13f1d5b6284d43bf273",
"revisionTime": "2018-03-21T19:18:39Z"
},
{
"checksumSHA1": "qF5wbamqBc44thWKDSmF8ayi6nI=",
"path": "github.com/hashicorp/vault-plugin-secrets-gcp/plugin/util",
"revision": "69b9785fc3a7b4e7ca40c13f1d5b6284d43bf273",
"revisionTime": "2018-03-21T19:18:39Z"
},
{
"checksumSHA1": "ZYuIUFGjAZ2rgy/zwdjfANFZc/U=",
"path": "github.com/hashicorp/vault-plugin-secrets-kv",