Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce the gcp_cloudstorage BundlePublisher plugin #4961

Merged
merged 3 commits into from
Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions doc/plugin_server_bundlepublisher_gcp_cloudstorage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Server plugin: BundlePublisher "gcp_cloudstorage"

The `gcp_cloudstorage` plugin puts the current trust bundle of the server in a designated
Google Cloud Storage bucket, keeping it updated.

The plugin accepts the following configuration options:

| Configuration | Description | Required | Default |
|----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|----------------------------------------------------------------|
| service_account_file | Path to the service account file used to authenticate with the Cloud Storage API. | No. | Value of `GOOGLE_APPLICATION_CREDENTIALS` environment variable.|
| bucket_name | The Google Cloud Storage bucket name to which the trust bundle is uploaded. | Yes. | |
| object_name | The object name inside the bucket. | Yes. | |
| format | Format in which the trust bundle is stored, <spiffe | jwks | pem>. See [Supported bundle formats](#supported-bundle-formats) for more details. | Yes. | |

## Supported bundle formats

The following bundle formats are supported:

### SPIFFE format

The trust bundle is represented as an RFC 7517 compliant JWK Set, with the specific parameters defined in the [SPIFFE Trust Domain and Bundle specification](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md#4-spiffe-bundle-format). Both the JWT authorities and the X.509 authorities are included.

### JWKS format

The trust bundle is encoded as an RFC 7517 compliant JWK Set, omitting SPIFFE-specific parameters. Both the JWT authorities and the X.509 authorities are included.

### PEM format

The trust bundle is formatted using PEM encoding. Only the X.509 authorities are included.

## Required permissions

The plugin requires the following IAM permissions be granted to the authenticated service account in the configured bucket:

```text
storage.objects.create
storage.objects.delete
```

The `storage.objects.delete` permission is required to overwrite the object when the bundle is updated.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible to update? instead of deleting? or that is the way gcp api works?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how the Google Cloud Storage API works, as documented here: https://cloud.google.com/storage/docs/uploading-objects#roles-and-permissions

storage.objects.delete
This permission is only required for uploads that overwrite an existing object.


## Sample configuration
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: old plugin used to have an example using service_account_file, what do you think about adding the same here?


The following configuration uploads the local trust bundle contents to the `example.org` object in the `spire-trust-bundle` bucket. The AWS access key id and secret access key are obtained from the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESSKEY environment variables.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this example is calling spire-bundle can you unify?


```hcl
BundlePublisher "gcp_cloudstorage" {
plugin_data {
bucket = "spire-bundle"
object_name = "example.org"
format = "spiffe"
}
}
```
5 changes: 3 additions & 2 deletions doc/spire_server.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This document is a configuration reference for SPIRE Server. It includes informa
| NodeAttestor | Implements validation logic for nodes attempting to assert their identity. Generally paired with an agent plugin of the same type. |
| UpstreamAuthority | Allows SPIRE server to integrate with existing PKI systems. |
| Notifier | Notified by SPIRE server for certain events that are happening or have happened. For events that are happening, the notifier can advise SPIRE server on the outcome. |
| BundlePublisher | Publishes trust bundles to additional locations. |
| BundlePublisher | Publishes the local trust bundle to a store. |

## Built-in plugins

Expand Down Expand Up @@ -41,7 +41,8 @@ This document is a configuration reference for SPIRE Server. It includes informa
| UpstreamAuthority | [cert-manager](/doc/plugin_server_upstreamauthority_cert_manager.md) | Uses a referenced cert-manager Issuer to request intermediate signing certificates. |
| Notifier | [gcs_bundle](/doc/plugin_server_notifier_gcs_bundle.md) | A notifier that pushes the latest trust bundle contents into an object in Google Cloud Storage. |
| Notifier | [k8sbundle](/doc/plugin_server_notifier_k8sbundle.md) | A notifier that pushes the latest trust bundle contents into a Kubernetes ConfigMap. |
| BundlePublisher | [aws_s3](/doc/plugin_server_bundlepublisher_aws_s3.md) | Publishes trust bundles to an Amazon S3 bucket. |
| BundlePublisher | [aws_s3](/doc/plugin_server_bundlepublisher_aws_s3.md) | Publishes the trust bundle to an Amazon S3 bucket. |
| BundlePublisher | [gcp_cloudstorage](/doc/plugin_server_bundlepublisher_gcp_cloudstorage.md) | Publishes the trust bundle to a Google Cloud Storage bucket. |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you resolve identation issues in this table?


## Server configuration file

Expand Down
2 changes: 2 additions & 0 deletions pkg/server/catalog/bundlepublisher.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"github.com/spiffe/spire/pkg/common/catalog"
"github.com/spiffe/spire/pkg/server/plugin/bundlepublisher"
"github.com/spiffe/spire/pkg/server/plugin/bundlepublisher/awss3"
"github.com/spiffe/spire/pkg/server/plugin/bundlepublisher/gcpcloudstorage"
)

type bundlePublisherRepository struct {
Expand All @@ -25,6 +26,7 @@ func (repo *bundlePublisherRepository) Versions() []catalog.Version {
func (repo *bundlePublisherRepository) BuiltIns() []catalog.BuiltIn {
return []catalog.BuiltIn{
awss3.BuiltIn(),
gcpcloudstorage.BuiltIn(),
}
}

Expand Down
22 changes: 22 additions & 0 deletions pkg/server/plugin/bundlepublisher/gcpcloudstorage/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package gcpcloudstorage

import (
"context"
"io"

"cloud.google.com/go/storage"
"google.golang.org/api/option"
)

type gcsService interface {
Bucket(name string) *storage.BucketHandle
Close() error
}

func newGCSClient(ctx context.Context, opts ...option.ClientOption) (gcsService, error) {
return storage.NewClient(ctx, opts...)
}

func newStorageWriter(ctx context.Context, o *storage.ObjectHandle) io.WriteCloser {
return o.NewWriter(ctx)
}
259 changes: 259 additions & 0 deletions pkg/server/plugin/bundlepublisher/gcpcloudstorage/gcpcloudstorage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
package gcpcloudstorage

import (
"context"
"io"
"sync"

"cloud.google.com/go/storage"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/hcl"
"github.com/spiffe/spire-plugin-sdk/pluginsdk/support/bundleformat"
bundlepublisherv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/bundlepublisher/v1"
"github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/types"
configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1"
"github.com/spiffe/spire/pkg/common/catalog"
"github.com/spiffe/spire/pkg/common/telemetry"
"google.golang.org/api/option"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)

const (
pluginName = "gcp_cloudstorage"
)

type pluginHooks struct {
newGCSClientFunc func(ctx context.Context, opts ...option.ClientOption) (gcsService, error)
newStorageWriterFunc func(ctx context.Context, o *storage.ObjectHandle) io.WriteCloser
wroteObjectFunc func() // Test hook called when an object was written.
}

func BuiltIn() catalog.BuiltIn {
return builtin(New())
}

func New() *Plugin {
return newPlugin(newGCSClient, newStorageWriter)
}

// Config holds the configuration of the plugin.
type Config struct {
BucketName string `hcl:"bucket_name" json:"bucket_name"`
ObjectName string `hcl:"object_name" json:"object_name"`
Format string `hcl:"format" json:"format"`
ServiceAccountFile string `hcl:"service_account_file" json:"service_account_file"`

// bundleFormat is used to store the content of Format, parsed
// as bundleformat.Format.
bundleFormat bundleformat.Format
}

// Plugin is the main representation of this bundle publisher plugin.
type Plugin struct {
bundlepublisherv1.UnsafeBundlePublisherServer
configv1.UnsafeConfigServer

config *Config
configMtx sync.RWMutex

bundle *types.Bundle
bundleMtx sync.RWMutex

hooks pluginHooks
gcsClient gcsService
log hclog.Logger
}

// SetLogger sets a logger in the plugin.
func (p *Plugin) SetLogger(log hclog.Logger) {
p.log = log
}

// Configure configures the plugin.
func (p *Plugin) Configure(ctx context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) {
config, err := parseAndValidateConfig(req.HclConfiguration)
if err != nil {
return nil, err
}

var opts []option.ClientOption
if config.ServiceAccountFile != "" {
opts = append(opts, option.WithCredentialsFile(config.ServiceAccountFile))
}

gcsClient, err := p.hooks.newGCSClientFunc(ctx, opts...)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create client: %v", err)
}
p.gcsClient = gcsClient

p.setConfig(config)
p.setBundle(nil)
return &configv1.ConfigureResponse{}, nil
}

// PublishBundle puts the bundle in the configured GCS bucket and object name.
func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.PublishBundleRequest) (*bundlepublisherv1.PublishBundleResponse, error) {
config, err := p.getConfig()
if err != nil {
return nil, err
}

if req.Bundle == nil {
return nil, status.Error(codes.InvalidArgument, "missing bundle in request")
}

currentBundle := p.getBundle()
if proto.Equal(req.Bundle, currentBundle) {
// Bundle not changed. No need to publish.
return &bundlepublisherv1.PublishBundleResponse{}, nil
}

formatter := bundleformat.NewFormatter(req.Bundle)
bundleBytes, err := formatter.Format(config.bundleFormat)
if err != nil {
return nil, status.Errorf(codes.Internal, "could not format bundle: %v", err.Error())
}

bucketHandle := p.gcsClient.Bucket(config.BucketName)
if bucketHandle == nil { // Purely defensive, the Bucket function implemented in GCS always returns a BucketHandle.
return nil, status.Error(codes.Internal, "could not get bucket handle")
}

objectHandle := bucketHandle.Object(config.ObjectName)
if objectHandle == nil { // Purely defensive, the Object function implemented in GCS always returns an ObjectHandle.
return nil, status.Error(codes.Internal, "could not get object handle")
}

storageWriter := p.hooks.newStorageWriterFunc(ctx, objectHandle)
if storageWriter == nil { // Purely defensive, the NewWriter function implemented in GCS always returns a storage writer
return nil, status.Error(codes.Internal, "could not initialize storage writer")
}

_, err = storageWriter.Write(bundleBytes)
// The number of bytes written can be safely ignored. To determine if an
// object was successfully uploaded, we need to look at the error returned
// from storageWriter.Close().
if err != nil {
// Close the storage writer before returning.
if closeErr := storageWriter.Close(); err != nil {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why no to use a defer after writer was created?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the _, err = storageWriter.Write(bundleBytes) call returns an error, we want to first call Close() and then return the error returned by Write, so I don't think that using defer would help here.

p.log.With(telemetry.Error, closeErr).Error("Failed to close storage writer")
}
return nil, status.Errorf(codes.Internal, "failed to write bundle: %v", err)
}

if err := storageWriter.Close(); err != nil {
return nil, status.Errorf(codes.Internal, "failed to close storage writer: %v", err)
}

if p.hooks.wroteObjectFunc != nil {
p.hooks.wroteObjectFunc()
}

p.setBundle(req.Bundle)
p.log.Debug("Bundle published")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there some metadata that can be added as field here? that simplify debugging?

return &bundlepublisherv1.PublishBundleResponse{}, nil
}

// Close is called when the plugin is unloaded. Closes the client.
func (p *Plugin) Close() error {
if p.gcsClient == nil {
return nil
}
p.log.Debug("Closing the connection to the Cloud Storage API service")
return p.gcsClient.Close()
}

// getBundle gets the latest bundle that the plugin has.
func (p *Plugin) getBundle() *types.Bundle {
p.configMtx.RLock()
defer p.configMtx.RUnlock()

return p.bundle
}

// getConfig gets the configuration of the plugin.
func (p *Plugin) getConfig() (*Config, error) {
p.configMtx.RLock()
defer p.configMtx.RUnlock()

if p.config == nil {
return nil, status.Error(codes.FailedPrecondition, "not configured")
}
return p.config, nil
}

// setBundle updates the current bundle in the plugin with the provided bundle.
func (p *Plugin) setBundle(bundle *types.Bundle) {
p.bundleMtx.Lock()
defer p.bundleMtx.Unlock()

p.bundle = bundle
}

// setConfig sets the configuration for the plugin.
func (p *Plugin) setConfig(config *Config) {
p.configMtx.Lock()
defer p.configMtx.Unlock()

p.config = config
}

// builtin creates a new BundlePublisher built-in plugin.
func builtin(p *Plugin) catalog.BuiltIn {
return catalog.MakeBuiltIn(pluginName,
bundlepublisherv1.BundlePublisherPluginServer(p),
configv1.ConfigServiceServer(p),
)
}

// newPlugin returns a new plugin instance.
func newPlugin(newGCSClientFunc func(ctx context.Context, opts ...option.ClientOption) (gcsService, error),
newStorageWriterFunc func(ctx context.Context, o *storage.ObjectHandle) io.WriteCloser) *Plugin {
return &Plugin{
hooks: pluginHooks{
newGCSClientFunc: newGCSClientFunc,
newStorageWriterFunc: newStorageWriterFunc,
},
}
}

// parseAndValidateConfig returns an error if any configuration provided does
// not meet acceptable criteria
func parseAndValidateConfig(c string) (*Config, error) {
config := new(Config)

if err := hcl.Decode(config, c); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "unable to decode configuration: %v", err)
}

if config.BucketName == "" {
return nil, status.Error(codes.InvalidArgument, "configuration is missing the bucket name")
}

if config.ObjectName == "" {
return nil, status.Error(codes.InvalidArgument, "configuration is missing the object name")
}

if config.Format == "" {
return nil, status.Error(codes.InvalidArgument, "configuration is missing the bundle format")
}
bundleFormat, err := bundleformat.FromString(config.Format)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "could not parse bundle format from configuration: %v", err)
}
// The bundleformat package may support formats that this plugin does not
// support. Validate that the format is a supported format in this plugin.
switch bundleFormat {
case bundleformat.JWKS:
case bundleformat.SPIFFE:
case bundleformat.PEM:
default:
return nil, status.Errorf(codes.InvalidArgument, "format not supported %q", config.Format)
}

config.bundleFormat = bundleFormat
return config, nil
}
Loading
Loading