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

Initial S3 Store functionality #13

Merged
merged 1 commit into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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
56 changes: 54 additions & 2 deletions api/v1beta1/emergencyaccount_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ type TokenStoreSpec struct {
// +kubebuilder:validation:Required
Name string `json:"name"`
// Type defines the type of the store to use.
// Currently `secret`` and `log` stores are supported.
// Currently `secret`, `s3`, and `log` stores are supported.
// The stores can be further configured in the corresponding storeSpec.
// +kubebuilder:validation:Required
// +kubebuilder:validation:Enum=secret;log
// +kubebuilder:validation:Enum=secret;log;s3
Type string `json:"type"`

// SecretSpec configures the secret store.
Expand All @@ -64,6 +64,58 @@ type TokenStoreSpec struct {
// LogSpec configures the log store.
// The log store outputs the token to the log but does not store it anywhere.
LogSpec LogStoreSpec `json:"logStore,omitempty"`
// S3Spec configures the S3 store.
// The S3 store saves the tokens in an S3 bucket.
S3Spec S3StoreSpec `json:"s3Store,omitempty"`
}

// S3StoreSpec configures the S3 store.
// The S3 store saves the tokens in an S3 bucket with optional encryption using PGP public keys.
type S3StoreSpec struct {
// ObjectNameTemplate is the template for the object name to use.
// Sprig functions can be used to generate the object name.
// If not set, the object name is the name of the EmergencyAccount.
// The name of the EmergencyAccount can be accessed with `{{ .Name }}`.
// The namespace of the EmergencyAccount can be accessed with `{{ .Namespace }}`.
// The full EmergencyAccount object can be accessed with `{{ .EmergencyAccount }}`.
// Additional context can be passed with the `objectNameTemplateContext` field and is accessible with `{{ .Context.<key> }}`.
// +kubebuilder:validation:Optional
ObjectNameTemplate string `json:"objectNameTemplate,omitempty"`
// ObjectNameTemplateContext is the additional context to use for the object name template.
// +kubebuilder:validation:Optional
ObjectNameTemplateContext map[string]string `json:"objectNameTemplateContext,omitempty"`

S3 S3Spec `json:"s3"`
// Encryption defines the encryption settings for the S3 store.
// If not set, the tokens are stored unencrypted.
// +kubebuilder:validation:Optional
Encryption S3EncryptionSpec `json:"encryption,omitempty"`
}

type S3Spec struct {
// Endpoint is the S3 endpoint to use.
Endpoint string `json:"endpoint"`
// Bucket is the S3 bucket to use.
Bucket string `json:"bucket"`

// AccessKeyId and SecretAccessKey are the S3 credentials to use.
AccessKeyId string `json:"accessKeyId"`
// SecretAccessKey is the S3 secret access key to use.
SecretAccessKey string `json:"secretAccessKey"`

// Region is the AWS region to use.
Region string `json:"region,omitempty"`
// Insecure allows to use an insecure connection to the S3 endpoint.
Insecure bool `json:"insecure,omitempty"`
}

type S3EncryptionSpec struct {
// Encrypt defines if the tokens should be encrypted.
// If not set, the tokens are stored unencrypted.
Encrypt bool `json:"encrypt,omitempty"`
// PGPKeys is a list of PGP public keys to encrypt the tokens with.
// At least one key must be given if encryption is enabled.
PGPKeys []string `json:"pgpKeys,omitempty"`
}

// SecretStoreSpec configures the secret store.
Expand Down
64 changes: 63 additions & 1 deletion api/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 73 additions & 2 deletions config/crd/bases/cluster.appuio.io_emergencyaccounts.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,89 @@ spec:
description: Name is the name of the store. Must be unique within
the EmergencyAccount
type: string
s3Store:
description: S3Spec configures the S3 store. The S3 store saves
the tokens in an S3 bucket.
properties:
encryption:
description: Encryption defines the encryption settings
for the S3 store. If not set, the tokens are stored unencrypted.
properties:
encrypt:
description: Encrypt defines if the tokens should be
encrypted. If not set, the tokens are stored unencrypted.
type: boolean
pgpKeys:
description: PGPKeys is a list of PGP public keys to
encrypt the tokens with. At least one key must be
given if encryption is enabled.
items:
type: string
type: array
type: object
objectNameTemplate:
description: ObjectNameTemplate is the template for the
object name to use. Sprig functions can be used to generate
the object name. If not set, the object name is the name
of the EmergencyAccount. The name of the EmergencyAccount
can be accessed with `{{ .Name }}`. The namespace of the
EmergencyAccount can be accessed with `{{ .Namespace }}`.
The full EmergencyAccount object can be accessed with
`{{ .EmergencyAccount }}`. Additional context can be passed
with the `objectNameTemplateContext` field and is accessible
with `{{ .Context.<key> }}`.
type: string
objectNameTemplateContext:
additionalProperties:
type: string
description: ObjectNameTemplateContext is the additional
context to use for the object name template.
type: object
s3:
properties:
accessKeyId:
description: AccessKeyId and SecretAccessKey are the
S3 credentials to use.
type: string
bucket:
description: Bucket is the S3 bucket to use.
type: string
endpoint:
description: Endpoint is the S3 endpoint to use.
type: string
insecure:
description: Insecure allows to use an insecure connection
to the S3 endpoint.
type: boolean
region:
description: Region is the AWS region to use.
type: string
secretAccessKey:
description: SecretAccessKey is the S3 secret access
key to use.
type: string
required:
- accessKeyId
- bucket
- endpoint
- secretAccessKey
type: object
required:
- s3
type: object
secretStore:
description: SecretSpec configures the secret store. The secret
store saves the tokens in a secret in the same namespace as
the EmergencyAccount.
type: object
type:
description: Type defines the type of the store to use. Currently
`secret`` and `log` stores are supported. The stores can be
further configured in the corresponding storeSpec.
`secret`, `s3`, and `log` stores are supported. The stores
can be further configured in the corresponding storeSpec.
enum:
- secret
- log
- s3
type: string
required:
- name
Expand Down
146 changes: 146 additions & 0 deletions controllers/stores/s3_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package stores

import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"text/template"

"github.com/Masterminds/sprig/v3"
"github.com/ProtonMail/gopenpgp/v2/helper"
"github.com/appuio/emergency-credentials-controller/pkg/utils"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"go.uber.org/multierr"

emcv1beta1 "github.com/appuio/emergency-credentials-controller/api/v1beta1"
)

// MinioClient partially implements the minio.Client interface.
type MinioClient interface {
PutObject(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (info minio.UploadInfo, err error)
}

type S3Store struct {
minioClientFactory func(emcv1beta1.S3StoreSpec) (MinioClient, error)
spec emcv1beta1.S3StoreSpec
}

var _ TokenStorer = &S3Store{}

// NewS3Store creates a new S3Store
func NewS3Store(spec emcv1beta1.S3StoreSpec) *S3Store {
return NewS3StoreWithClientFactory(spec, DefaultClientFactory)
}

// NewS3StoreWithClientFactory creates a new S3Store with the given client factory.
func NewS3StoreWithClientFactory(spec emcv1beta1.S3StoreSpec, minioClientFactory func(emcv1beta1.S3StoreSpec) (MinioClient, error)) *S3Store {
return &S3Store{spec: spec, minioClientFactory: minioClientFactory}
}

// DefaultClientFactory is the default factory for creating a MinioClient.
func DefaultClientFactory(spec emcv1beta1.S3StoreSpec) (MinioClient, error) {
return minio.New(spec.S3.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(spec.S3.AccessKeyId, spec.S3.SecretAccessKey, ""),
Secure: !spec.S3.Insecure,
Region: spec.S3.Region,
})
}

// StoreToken stores the token in the S3 bucket.
// If encryption is enabled, the token is encrypted with the given PGP public keys.
func (ss *S3Store) StoreToken(ctx context.Context, ea emcv1beta1.EmergencyAccount, token string) (string, error) {
objectname := ea.Name
if ss.spec.ObjectNameTemplate != "" {
t, err := template.New("fileName").Funcs(sprig.TxtFuncMap()).Parse(ss.spec.ObjectNameTemplate)
if err != nil {
return "", fmt.Errorf("unable to parse file name template: %w", err)
}
buf := new(strings.Builder)
if err := t.Execute(buf, struct {
Name string
Namespace string
EmergencyAccount emcv1beta1.EmergencyAccount
Context map[string]string
}{
Name: ea.Name,
Namespace: ea.Namespace,
EmergencyAccount: ea,
Context: ss.spec.ObjectNameTemplateContext,
}); err != nil {
return "", fmt.Errorf("unable to execute file name template: %w", err)
}
objectname = buf.String()
}

cli, err := ss.minioClientFactory(ss.spec)
if err != nil {
return "", fmt.Errorf("unable to create S3 client: %w", err)
}

if ss.spec.Encryption.Encrypt {
token, err = encrypt(token, ss.spec.Encryption.PGPKeys)
if err != nil {
return "", fmt.Errorf("unable to encrypt token: %w", err)
}
}

tr := strings.NewReader(token)
info, err := cli.PutObject(ctx, ss.spec.S3.Bucket, objectname, tr, int64(tr.Len()), minio.PutObjectOptions{})
if err != nil {
return "", fmt.Errorf("unable to store token: %w", err)
}

return info.Key, nil
}

// EncryptedToken is the JSON structure of an encrypted token.
type EncryptedToken struct {
Secrets []EncryptedTokenSecret `json:"secrets"`
}

// EncryptedTokenSecret is the JSON structure of an encrypted token secret.
type EncryptedTokenSecret struct {
Data string `json:"data"`
}

// encrypt encrypts the token with the given PGP public keys.
// The token is encrypted with each key and the resulting encrypted tokens are returned as a JSON array.
func encrypt(token string, pgpKeys []string) (string, error) {
if len(pgpKeys) == 0 {
return "", fmt.Errorf("no PGP public keys given")
}
keys := []string{}
for _, key := range pgpKeys {
sk, err := utils.SplitPublicKeyBlocks(key)
if err != nil {
return "", fmt.Errorf("unable to parse PGP public key: %w", err)
}
keys = append(keys, sk...)
}

encrypted := make([]EncryptedTokenSecret, 0, len(keys))
errs := []error{}
for _, key := range keys {
enc, err := helper.EncryptMessageArmored((key), token)
if err != nil {
errs = append(errs, err)
continue
}
encrypted = append(encrypted, EncryptedTokenSecret{Data: enc})
}
if multierr.Combine(errs...) != nil {
return "", fmt.Errorf("unable to fully encrypt token: %w", multierr.Combine(errs...))
}

s, err := json.Marshal(EncryptedToken{
Secrets: encrypted,
})
if err != nil {
return "", fmt.Errorf("unable to marshal encrypted token: %w", err)
}

return string(s), nil
}
Loading