feat(plugin): Implement OnCreateStorageBucket and ValidatePermissions

This commit is contained in:
Hugo committed 2024-03-04 19:42:01 +00:00
1 parent 317f73eb5d
commit 07ec485161
11 files changed
+1872 -26

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package storage
import (
"fmt"
"strings"
"github.com/hashicorp/boundary-plugin-minio/internal/errors"
"github.com/hashicorp/boundary-plugin-minio/internal/values"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
const (
ConstEndpointUrl = "endpoint_url"
ConstAccessKeyId = "access_key_id"
ConstSecretAccessKey = "secret_access_key"
ConstRegion = "region"
)
type StorageAttributes struct {
// AccessKeyId is the MinIO Access Key Id. This field is required and comes
// from user input.
AccessKeyId string
// SecretAccessKey is the MinIO Secret Access Key. This field is required
// and comes from user input.
SecretAccessKey string
// EndpointUrl is the MinIO server URL. This field is required and comes
// from user input.
EndpointUrl string
// Region is the MinIO region. This field is optional and comes from user
// input.
Region string
// UseSSL determines if the MinIO SDK will use SSL when communicating with
// the server. This field is determined based on the endpoint URL prefix.
UseSSL bool
}
// ToMap returns a map StorageAttributes's secret fields as a map.
func (sa *StorageAttributes) SecretsToMap() map[string]any {
return map[string]any{
ConstAccessKeyId: sa.AccessKeyId,
ConstSecretAccessKey: sa.SecretAccessKey,
}
}
func getStorageAttributes(inAttributes *structpb.Struct, inSecrets *structpb.Struct) (*StorageAttributes, error) {
if inAttributes == nil || inAttributes.GetFields() == nil || len(inAttributes.GetFields()) == 0 {
return nil, status.Error(codes.InvalidArgument, "empty attributes input")
}
if inSecrets == nil || inSecrets.GetFields() == nil || len(inSecrets.GetFields()) == 0 {
return nil, status.Error(codes.InvalidArgument, "empty secrets input")
}
badFields := make(map[string]string)
unknownAttrFields := values.StructFields(inAttributes)
endpointUrl, err := values.GetStringValue(inAttributes, ConstEndpointUrl, true)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstEndpointUrl)] = err.Error()
}
delete(unknownAttrFields, ConstEndpointUrl)
// The MinIO SDK doesn't want a fully-qualified URL, so we have to strip it
// here and transform it to a combination of URL + useSSL.
useSSL := true // Assume SSL by default.
if strings.HasPrefix(endpointUrl, "https://") {
endpointUrl = strings.TrimPrefix(endpointUrl, "https://")
} else if strings.HasPrefix(endpointUrl, "http://") {
useSSL = false
endpointUrl = strings.TrimPrefix(endpointUrl, "http://")
} else {
badFields[fmt.Sprintf("attributes.%s.format", ConstEndpointUrl)] = "unknown protocol, should be http:// or https://"
}
region, err := values.GetStringValue(inAttributes, ConstRegion, false)
if err != nil {
badFields[fmt.Sprintf("attributes.%s", ConstRegion)] = err.Error()
}
delete(unknownAttrFields, ConstRegion)
for s := range unknownAttrFields {
badFields[fmt.Sprintf("attributes.%s", s)] = "unrecognized field"
}
unknownSecretFields := values.StructFields(inSecrets)
accessKeyId, err := values.GetStringValue(inSecrets, ConstAccessKeyId, true)
if err != nil {
badFields[fmt.Sprintf("secrets.%s", ConstAccessKeyId)] = err.Error()
}
delete(unknownSecretFields, ConstAccessKeyId)
secretAccessKey, err := values.GetStringValue(inSecrets, ConstSecretAccessKey, true)
if err != nil {
badFields[fmt.Sprintf("secrets.%s", ConstSecretAccessKey)] = err.Error()
}
delete(unknownSecretFields, ConstSecretAccessKey)
for s := range unknownSecretFields {
badFields[fmt.Sprintf("secrets.%s", s)] = "unrecognized field"
}
if len(badFields) > 0 {
return nil, errors.InvalidArgumentError("invalid or unrecognized attributes/secrets found", badFields)
}
return &StorageAttributes{
AccessKeyId: accessKeyId,
SecretAccessKey: secretAccessKey,
EndpointUrl: endpointUrl,
Region: region,
UseSSL: useSSL,
}, nil
}
+375
View File
@@ -0,0 +1,375 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package storage
import (
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)
func TestGetStorageAttributes(t *testing.T) {
tests := []struct {
name string
inAttributes *structpb.Struct
inSecrets *structpb.Struct
expStorageAttributes *StorageAttributes
expErrMsg string
}{
{
name: "nilAttributes",
inAttributes: nil,
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
"foo": structpb.NewStringValue("bar"),
},
},
expErrMsg: "empty attributes input",
},
{
name: "nilAttributesFields",
inAttributes: &structpb.Struct{Fields: nil},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
"foo": structpb.NewStringValue("bar"),
},
},
expErrMsg: "empty attributes input",
},
{
name: "emptyAttributesFields",
inAttributes: &structpb.Struct{Fields: map[string]*structpb.Value{}},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
"foo": structpb.NewStringValue("bar"),
},
},
expErrMsg: "empty attributes input",
},
{
name: "nilSecrets",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
"foo": structpb.NewStringValue("bar"),
},
},
inSecrets: nil,
expErrMsg: "empty secrets input",
},
{
name: "nilSecretsFields",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
"foo": structpb.NewStringValue("bar"),
},
},
inSecrets: &structpb.Struct{Fields: nil},
expErrMsg: "empty secrets input",
},
{
name: "emptySecretsFields",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
"foo": structpb.NewStringValue("bar"),
},
},
inSecrets: &structpb.Struct{Fields: map[string]*structpb.Value{}},
expErrMsg: "empty secrets input",
},
{
name: "noEndpointUrlAttribute",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
"foo": structpb.NewStringValue("bar"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("bar"),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expErrMsg: "attributes.endpoint_url: missing required value \"endpoint_url\"",
},
{
name: "emptyEndpointUrlAttribute",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue(""),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("bar"),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expErrMsg: "attributes.endpoint_url: value \"endpoint_url\" cannot be empty",
},
{
name: "endpointUrlAttributeBadType",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewBoolValue(true),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("bar"),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expErrMsg: "attributes.endpoint_url: unexpected type for value \"endpoint_url\": want string, got bool",
},
{
name: "badEndpointUrlAttributeFormat",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("bad://foo.bar"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("bar"),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expErrMsg: "attributes.endpoint_url.format: unknown protocol, should be http:// or https://",
},
{
name: "regionAttributeBadType",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://foo.bar"),
ConstRegion: structpb.NewBoolValue(true),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("bar"),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expErrMsg: "attributes.region: unexpected type for value \"region\": want string, got bool",
},
{
name: "unknownAttribute",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://foo.bar"),
ConstRegion: structpb.NewStringValue("us-east-1"),
"foo": structpb.NewStringValue("bar"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("bar"),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expErrMsg: "attributes.foo: unrecognized field",
},
{
name: "noAccessKeyIdSecret",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://foo.bar"),
ConstRegion: structpb.NewStringValue("us-east-1"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expErrMsg: "secrets.access_key_id: missing required value \"access_key_id\"",
},
{
name: "emptyAccessKeyIdSecret",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://foo.bar"),
ConstRegion: structpb.NewStringValue("us-east-1"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue(""),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expErrMsg: "secrets.access_key_id: value \"access_key_id\" cannot be empty",
},
{
name: "accessKeyIdSecretBadType",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://foo.bar"),
ConstRegion: structpb.NewStringValue("us-east-1"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewBoolValue(true),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expErrMsg: "secrets.access_key_id: unexpected type for value \"access_key_id\": want string, got bool",
},
{
name: "noSecretAccessKeySecret",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://foo.bar"),
ConstRegion: structpb.NewStringValue("us-east-1"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("bar"),
},
},
expErrMsg: "secrets.secret_access_key: missing required value \"secret_access_key\"",
},
{
name: "emptySecretAccessKeySecret",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://foo.bar"),
ConstRegion: structpb.NewStringValue("us-east-1"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("bar"),
ConstSecretAccessKey: structpb.NewStringValue(""),
},
},
expErrMsg: "secrets.secret_access_key: value \"secret_access_key\" cannot be empty",
},
{
name: "secretAccessKeySecretBadType",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://foo.bar"),
ConstRegion: structpb.NewStringValue("us-east-1"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("bar"),
ConstSecretAccessKey: structpb.NewBoolValue(true),
},
},
expErrMsg: "secrets.secret_access_key: unexpected type for value \"secret_access_key\": want string, got bool",
},
{
name: "unknownSecret",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://foo.bar"),
ConstRegion: structpb.NewStringValue("us-east-1"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("bar"),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
"foo": structpb.NewStringValue("bar"),
},
},
expErrMsg: "secrets.foo: unrecognized field",
},
{
name: "successWithNoSSL",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://foo.bar"),
ConstRegion: structpb.NewStringValue("us-east-1"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("foo"),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expStorageAttributes: &StorageAttributes{
AccessKeyId: "foo",
SecretAccessKey: "bar",
EndpointUrl: "foo.bar",
Region: "us-east-1",
UseSSL: false,
},
},
{
name: "successWithSSL",
inAttributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("https://foo.bar"),
ConstRegion: structpb.NewStringValue("us-east-1"),
},
},
inSecrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue("foo"),
ConstSecretAccessKey: structpb.NewStringValue("bar"),
},
},
expStorageAttributes: &StorageAttributes{
AccessKeyId: "foo",
SecretAccessKey: "bar",
EndpointUrl: "foo.bar",
Region: "us-east-1",
UseSSL: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sa, err := getStorageAttributes(tt.inAttributes, tt.inSecrets)
if tt.expErrMsg != "" {
require.ErrorContains(t, err, tt.expErrMsg)
require.Nil(t, sa)
return
}
require.NoError(t, err)
require.NotNil(t, sa)
require.EqualValues(t, tt.expStorageAttributes, sa)
})
}
}
func TestStorageAttributesSecretsToMap(t *testing.T) {
tests := []struct {
name string
in *StorageAttributes
expMap map[string]any
}{
{
name: "completeObject",
in: &StorageAttributes{
AccessKeyId: "access_key_id_value",
SecretAccessKey: "secret_access_key_value",
EndpointUrl: "endpoint_url_value",
Region: "region_value",
UseSSL: true,
},
expMap: map[string]any{
"access_key_id": "access_key_id_value",
"secret_access_key": "secret_access_key_value",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := tt.in.SecretsToMap()
require.EqualValues(t, tt.expMap, m)
})
}
}
+135 -2
View File
@@ -4,10 +4,20 @@
package storage
import (
"bytes"
"context"
"fmt"
"path"
"github.com/google/uuid"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
madmin "github.com/minio/madmin-go/v3"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
var _ pb.StoragePluginServiceServer = (*StoragePlugin)(nil)
@@ -20,7 +30,45 @@ type StoragePlugin struct {
// OnCreateStorageBucket is a hook that runs when a storage bucket is created.
func (sp *StoragePlugin) OnCreateStorageBucket(ctx context.Context, req *pb.OnCreateStorageBucketRequest) (*pb.OnCreateStorageBucketResponse, error) {
return nil, fmt.Errorf("unimplemented")
bucket := req.GetBucket()
if bucket == nil {
return nil, status.Error(codes.InvalidArgument, "no storage bucket information found")
}
if bucket.GetBucketName() == "" {
return nil, status.Error(codes.InvalidArgument, "storage bucket name is required")
}
sa, err := getStorageAttributes(bucket.GetAttributes(), bucket.GetSecrets())
if err != nil {
return nil, err // getStorageAttributes already returns a "status"-type error.
}
err = ensureServiceAccount(ctx, sa)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to ensure service account: %v", err)
}
cl, err := minio.New(sa.EndpointUrl, &minio.Options{
Creds: credentials.NewStaticV4(sa.AccessKeyId, sa.SecretAccessKey, ""),
Secure: sa.UseSSL,
Region: sa.Region,
})
if err != nil {
return nil, status.Errorf(codes.Unknown, "failed to create minio sdk client: %v", err)
}
err = dryRun(ctx, cl, bucket)
if err != nil {
return nil, status.Errorf(codes.Unknown, "failed to verify provided minio environment: %v", err)
}
persistedData, err := structpb.NewStruct(sa.SecretsToMap())
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert storage attributes to protobuf struct: %v", err)
}
return &pb.OnCreateStorageBucketResponse{
Persisted: &storagebuckets.StorageBucketPersisted{Data: persistedData},
}, nil
}
// OnUpdateStorageBucket is a hook that runs when a storage bucket is updated.
@@ -36,7 +84,39 @@ func (sp *StoragePlugin) OnDeleteStorageBucket(ctx context.Context, req *pb.OnDe
// ValidatePermissions is a hook that checks if the secrets associated with the
// storage bucket meet the requirements of the plugin.
func (sp *StoragePlugin) ValidatePermissions(ctx context.Context, req *pb.ValidatePermissionsRequest) (*pb.ValidatePermissionsResponse, error) {
return nil, fmt.Errorf("unimplemented")
bucket := req.GetBucket()
if bucket == nil {
return nil, status.Error(codes.InvalidArgument, "no storage bucket information found")
}
if bucket.GetBucketName() == "" {
return nil, status.Error(codes.InvalidArgument, "storage bucket name is required")
}
sa, err := getStorageAttributes(bucket.GetAttributes(), bucket.GetSecrets())
if err != nil {
return nil, err // getStorageAttributes already returns a "status"-type error.
}
err = ensureServiceAccount(ctx, sa)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to ensure service account: %v", err)
}
cl, err := minio.New(sa.EndpointUrl, &minio.Options{
Creds: credentials.NewStaticV4(sa.AccessKeyId, sa.SecretAccessKey, ""),
Secure: sa.UseSSL,
Region: sa.Region,
})
if err != nil {
return nil, status.Errorf(codes.Unknown, "failed to create minio sdk client: %v", err)
}
err = dryRun(ctx, cl, bucket)
if err != nil {
return nil, status.Errorf(codes.Unknown, "failed to verify provided minio environment: %v", err)
}
return &pb.ValidatePermissionsResponse{}, nil
}
// HeadObject is a hook that retrieves metadata about an object.
@@ -60,3 +140,56 @@ func (sp *StoragePlugin) PutObject(ctx context.Context, req *pb.PutObjectRequest
func (sp *StoragePlugin) DeleteObjects(ctx context.Context, req *pb.DeleteObjectsRequest) (*pb.DeleteObjectsResponse, error) {
return nil, fmt.Errorf("unimplemented")
}
func dryRun(ctx context.Context, cl *minio.Client, bucket *storagebuckets.StorageBucket) error {
objectKey := path.Join(bucket.GetBucketPrefix(), uuid.New().String())
rd := bytes.NewReader([]byte("hashicorp boundary minio plugin access test"))
_, err := cl.PutObject(ctx, bucket.GetBucketName(), objectKey, rd, rd.Size(), minio.PutObjectOptions{})
if err != nil {
return fmt.Errorf("failed to put object at %q: %w", objectKey, err)
}
_, err = cl.StatObject(ctx, bucket.GetBucketName(), objectKey, minio.StatObjectOptions{})
if err != nil {
return fmt.Errorf("failed to stat object at %q: %w", objectKey, err)
}
_, err = cl.GetObject(ctx, bucket.GetBucketName(), objectKey, minio.GetObjectOptions{})
if err != nil {
return fmt.Errorf("failed to get object at %q: %w", objectKey, err)
}
oiCh := cl.ListObjects(ctx, bucket.GetBucketName(), minio.ListObjectsOptions{Recursive: true})
for oi := range oiCh {
if oi.Err != nil {
return fmt.Errorf("failed to list objects in bucket %q: %w", bucket.GetBucketName(), oi.Err)
}
}
err = cl.RemoveObject(ctx, bucket.GetBucketName(), objectKey, minio.RemoveObjectOptions{})
if err != nil {
return fmt.Errorf("failed to remove object at %q: %w", objectKey, err)
}
return nil
}
// ensureServiceAccount ensures the credentials we received belong to a MinIO
// service account. This plugin does not support using user credentials for its
// configuration.
func ensureServiceAccount(ctx context.Context, sa *StorageAttributes) error {
cl, err := madmin.NewWithOptions(sa.EndpointUrl, &madmin.Options{
Creds: credentials.NewStaticV4(sa.AccessKeyId, sa.SecretAccessKey, ""),
Secure: sa.UseSSL,
})
if err != nil {
return fmt.Errorf("failed to create madmin client: %w", err)
}
_, err = cl.InfoServiceAccount(ctx, sa.AccessKeyId)
if err != nil {
return fmt.Errorf("failed to obtain service account info: %w", err)
}
return nil
}
+567 -2
View File
@@ -4,16 +4,374 @@
package storage
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
internaltest "github.com/hashicorp/boundary-plugin-minio/internal/testing"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/storagebuckets"
"github.com/hashicorp/boundary/sdk/pbs/plugin"
"github.com/minio/madmin-go/v3"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/structpb"
)
func TestOnCreateStorageBucket(t *testing.T) {}
var (
policyDenyPutObject = []byte(`{
"Statement": [
{
"Action": [ "s3:*" ],
"Effect": "Allow",
"Resource": "arn:aws:s3:::*"
},
{
"Action": [ "s3:PutObject" ],
"Effect": "Deny",
"Resource": "arn:aws:s3:::*"
},
{
"Action": [
"admin:CreateServiceAccount",
"admin:RemoveServiceAccount"
],
"Effect": "Allow"
}
],
"Version": "2012-10-17"
}`)
policyDenyStatObject = []byte(`{
"Statement": [
{
"Action": [ "s3:*" ],
"Effect": "Allow",
"Resource": "arn:aws:s3:::*"
},
{
"Action": [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:GetObjectAttributes",
"s3:GetObjectVersionAttributes"
],
"Effect": "Deny",
"Resource": "arn:aws:s3:::*"
},
{
"Action": [
"admin:CreateServiceAccount",
"admin:RemoveServiceAccount"
],
"Effect": "Allow"
}
],
"Version": "2012-10-17"
}`)
policyDenyListBucket = []byte(`{
"Statement": [
{
"Action": [ "s3:*" ],
"Effect": "Allow",
"Resource": "arn:aws:s3:::*"
},
{
"Action": [ "s3:ListBucket" ],
"Effect": "Deny",
"Resource": "arn:aws:s3:::*"
},
{
"Action": [
"admin:CreateServiceAccount",
"admin:RemoveServiceAccount"
],
"Effect": "Allow"
}
],
"Version": "2012-10-17"
}`)
policyDenyDeleteObject = []byte(`{
"Statement": [
{
"Action": [ "s3:*" ],
"Effect": "Allow",
"Resource": "arn:aws:s3:::*"
},
{
"Action": [ "s3:DeleteObject" ],
"Effect": "Deny",
"Resource": "arn:aws:s3:::*"
},
{
"Action": [
"admin:CreateServiceAccount",
"admin:RemoveServiceAccount"
],
"Effect": "Allow"
}
],
"Version": "2012-10-17"
}`)
)
func TestOnCreateStorageBucket(t *testing.T) {
ctx := context.Background()
server := internaltest.NewMinioServer(t)
bucketName := "test-bucket"
err := server.Client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
require.NoError(t, err)
tests := []struct {
name string
minioClFn func(t *testing.T) *minio.Client
req *plugin.OnCreateStorageBucketRequest
expRsp *plugin.OnCreateStorageBucketResponse
expErrMsg string
}{
{
name: "nilBucket",
req: &plugin.OnCreateStorageBucketRequest{Bucket: nil},
expErrMsg: "no storage bucket information found",
},
{
name: "noBucketName",
req: &plugin.OnCreateStorageBucketRequest{
Bucket: &storagebuckets.StorageBucket{
BucketName: "",
},
},
expErrMsg: "storage bucket name is required",
},
{
name: "badStorageAttributes",
req: &plugin.OnCreateStorageBucketRequest{
Bucket: &storagebuckets.StorageBucket{
BucketName: bucketName,
},
},
expErrMsg: "empty attributes input",
},
{
name: "usingNonServiceAccount",
req: &plugin.OnCreateStorageBucketRequest{
Bucket: &storagebuckets.StorageBucket{
BucketName: bucketName,
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://" + server.ApiAddr),
},
},
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue(server.RootUsername),
ConstSecretAccessKey: structpb.NewStringValue(server.RootPassword),
},
},
},
},
expErrMsg: "The specified service account is not found (Specified service account does not exist)",
},
{
name: "dryRunFail",
req: &plugin.OnCreateStorageBucketRequest{
Bucket: &storagebuckets.StorageBucket{
BucketName: bucketName,
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://" + server.ApiAddr),
},
},
Secrets: &structpb.Struct{
Fields: func() map[string]*structpb.Value {
creds, err := server.AdminClient.AddServiceAccount(ctx, madmin.AddServiceAccountReq{
Policy: policyDenyPutObject,
})
require.NoError(t, err)
return map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue(creds.AccessKey),
ConstSecretAccessKey: structpb.NewStringValue(creds.SecretKey),
}
}(),
},
},
},
expErrMsg: "failed to verify provided minio environment: failed to put object",
},
{
name: "success",
req: &plugin.OnCreateStorageBucketRequest{
Bucket: &storagebuckets.StorageBucket{
BucketName: bucketName,
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://" + server.ApiAddr),
},
},
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue(server.ServiceAccountAccessKeyId),
ConstSecretAccessKey: structpb.NewStringValue(server.ServiceAccountSecretAccessKey),
},
},
},
},
expRsp: &plugin.OnCreateStorageBucketResponse{
Persisted: &storagebuckets.StorageBucketPersisted{
Data: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue(server.ServiceAccountAccessKeyId),
ConstSecretAccessKey: structpb.NewStringValue(server.ServiceAccountSecretAccessKey),
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sp := new(StoragePlugin)
rsp, err := sp.OnCreateStorageBucket(ctx, tt.req)
if tt.expErrMsg != "" {
require.ErrorContains(t, err, tt.expErrMsg)
require.Nil(t, rsp)
return
}
require.NoError(t, err)
require.NotNil(t, rsp)
require.Empty(t, cmp.Diff(tt.expRsp, rsp, protocmp.Transform()))
})
}
}
func TestOnUpdateStorageBucket(t *testing.T) {}
func TestOnDeleteStorageBucket(t *testing.T) {}
func TestValidatePermissions(t *testing.T) {}
func TestValidatePermissions(t *testing.T) {
ctx := context.Background()
server := internaltest.NewMinioServer(t)
bucketName := "test-bucket"
err := server.Client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
require.NoError(t, err)
tests := []struct {
name string
minioClFn func(t *testing.T) *minio.Client
req *plugin.ValidatePermissionsRequest
expErrMsg string
}{
{
name: "nilBucket",
req: &plugin.ValidatePermissionsRequest{Bucket: nil},
expErrMsg: "no storage bucket information found",
},
{
name: "noBucketName",
req: &plugin.ValidatePermissionsRequest{
Bucket: &storagebuckets.StorageBucket{
BucketName: "",
},
},
expErrMsg: "storage bucket name is required",
},
{
name: "badStorageAttributes",
req: &plugin.ValidatePermissionsRequest{
Bucket: &storagebuckets.StorageBucket{
BucketName: bucketName,
},
},
expErrMsg: "empty attributes input",
},
{
name: "usingNonServiceAccount",
req: &plugin.ValidatePermissionsRequest{
Bucket: &storagebuckets.StorageBucket{
BucketName: bucketName,
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://" + server.ApiAddr),
},
},
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue(server.RootUsername),
ConstSecretAccessKey: structpb.NewStringValue(server.RootPassword),
},
},
},
},
expErrMsg: "The specified service account is not found (Specified service account does not exist)",
},
{
name: "dryRunFail",
req: &plugin.ValidatePermissionsRequest{
Bucket: &storagebuckets.StorageBucket{
BucketName: bucketName,
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://" + server.ApiAddr),
},
},
Secrets: &structpb.Struct{
Fields: func() map[string]*structpb.Value {
creds, err := server.AdminClient.AddServiceAccount(ctx, madmin.AddServiceAccountReq{
Policy: policyDenyPutObject,
})
require.NoError(t, err)
return map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue(creds.AccessKey),
ConstSecretAccessKey: structpb.NewStringValue(creds.SecretKey),
}
}(),
},
},
},
expErrMsg: "failed to verify provided minio environment: failed to put object",
},
{
name: "success",
req: &plugin.ValidatePermissionsRequest{
Bucket: &storagebuckets.StorageBucket{
BucketName: bucketName,
Attributes: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstEndpointUrl: structpb.NewStringValue("http://" + server.ApiAddr),
},
},
Secrets: &structpb.Struct{
Fields: map[string]*structpb.Value{
ConstAccessKeyId: structpb.NewStringValue(server.ServiceAccountAccessKeyId),
ConstSecretAccessKey: structpb.NewStringValue(server.ServiceAccountSecretAccessKey),
},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sp := new(StoragePlugin)
rsp, err := sp.ValidatePermissions(ctx, tt.req)
if tt.expErrMsg != "" {
require.ErrorContains(t, err, tt.expErrMsg)
require.Nil(t, rsp)
return
}
require.NoError(t, err)
require.NotNil(t, rsp)
})
}
}
func TestHeadObject(t *testing.T) {}
@@ -22,3 +380,210 @@ func TestGetObject(t *testing.T) {}
func TestPutObject(t *testing.T) {}
func TestDeleteObjects(t *testing.T) {}
func TestDryRun(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
minioClFn func(t *testing.T, s *internaltest.MinioServer) *minio.Client
makeBucketFn func(t *testing.T, cl *minio.Client) string
// This test verifies that each message in the err slice is contained in
// the error string, effectively &&-ing the checks together.
expErrMsgs []string
}{
{
name: "bucketNotExists",
makeBucketFn: func(t *testing.T, cl *minio.Client) string { return "unknownbucket" },
expErrMsgs: []string{"The specified bucket does not exist"},
},
{
name: "putObjectFail",
minioClFn: func(t *testing.T, s *internaltest.MinioServer) *minio.Client {
creds, err := s.AdminClient.AddServiceAccount(ctx, madmin.AddServiceAccountReq{
Policy: policyDenyPutObject,
})
require.NoError(t, err)
cl, err := minio.New(s.ApiAddr, &minio.Options{
Creds: credentials.NewStaticV4(creds.AccessKey, creds.SecretKey, ""),
Secure: false,
})
require.NoError(t, err)
return cl
},
makeBucketFn: func(t *testing.T, cl *minio.Client) string {
bucketName := "testdryrun-putobjectfail"
err := cl.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
require.NoError(t, err)
return bucketName
},
expErrMsgs: []string{
"failed to put object",
"Access Denied",
},
},
{
name: "statObjectFail",
minioClFn: func(t *testing.T, s *internaltest.MinioServer) *minio.Client {
creds, err := s.AdminClient.AddServiceAccount(ctx, madmin.AddServiceAccountReq{
Policy: policyDenyStatObject,
})
require.NoError(t, err)
cl, err := minio.New(s.ApiAddr, &minio.Options{
Creds: credentials.NewStaticV4(creds.AccessKey, creds.SecretKey, ""),
Secure: false,
})
require.NoError(t, err)
return cl
},
makeBucketFn: func(t *testing.T, cl *minio.Client) string {
bucketName := "testdryrun-statobjectfail"
err := cl.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
require.NoError(t, err)
return bucketName
},
expErrMsgs: []string{
"failed to stat object",
"Access Denied",
},
},
{
name: "listObjectsFail",
minioClFn: func(t *testing.T, s *internaltest.MinioServer) *minio.Client {
creds, err := s.AdminClient.AddServiceAccount(ctx, madmin.AddServiceAccountReq{
Policy: policyDenyListBucket,
})
require.NoError(t, err)
cl, err := minio.New(s.ApiAddr, &minio.Options{
Creds: credentials.NewStaticV4(creds.AccessKey, creds.SecretKey, ""),
Secure: false,
})
require.NoError(t, err)
return cl
},
makeBucketFn: func(t *testing.T, cl *minio.Client) string {
bucketName := "testdryrun-listobjectsfail"
err := cl.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
require.NoError(t, err)
return bucketName
},
expErrMsgs: []string{
"failed to list objects in bucket",
"Access Denied",
},
},
{
name: "removeObjectFail",
minioClFn: func(t *testing.T, s *internaltest.MinioServer) *minio.Client {
creds, err := s.AdminClient.AddServiceAccount(ctx, madmin.AddServiceAccountReq{
Policy: policyDenyDeleteObject,
})
require.NoError(t, err)
cl, err := minio.New(s.ApiAddr, &minio.Options{
Creds: credentials.NewStaticV4(creds.AccessKey, creds.SecretKey, ""),
Secure: false,
})
require.NoError(t, err)
return cl
},
makeBucketFn: func(t *testing.T, cl *minio.Client) string {
bucketName := "testdryrun-removeobjectfail"
err := cl.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
require.NoError(t, err)
return bucketName
},
expErrMsgs: []string{
"failed to remove object",
"Access Denied",
},
},
{
name: "success",
makeBucketFn: func(t *testing.T, cl *minio.Client) string {
bucketName := "testdryrun-success"
err := cl.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
require.NoError(t, err)
return bucketName
},
},
}
server := internaltest.NewMinioServer(t)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
minioClient := server.Client
if tt.minioClFn != nil {
minioClient = tt.minioClFn(t, server)
}
bucketName := tt.makeBucketFn(t, minioClient)
err := dryRun(ctx, minioClient, &storagebuckets.StorageBucket{
BucketName: bucketName,
})
if len(tt.expErrMsgs) > 0 {
for _, errMsg := range tt.expErrMsgs {
require.ErrorContains(t, err, errMsg)
}
return
}
require.NoError(t, err)
})
}
}
func TestEnsureServiceAccount(t *testing.T) {
tests := []struct {
name string
saFunc func(s *internaltest.MinioServer) *StorageAttributes
expErrMsg string
}{
{
name: "nonServiceAccount",
saFunc: func(s *internaltest.MinioServer) *StorageAttributes {
return &StorageAttributes{
AccessKeyId: s.RootUsername,
SecretAccessKey: s.RootPassword,
EndpointUrl: s.ApiAddr,
UseSSL: false,
}
},
expErrMsg: "The specified service account is not found (Specified service account does not exist)",
},
{
name: "serviceAccount",
saFunc: func(s *internaltest.MinioServer) *StorageAttributes {
return &StorageAttributes{
AccessKeyId: s.ServiceAccountAccessKeyId,
SecretAccessKey: s.ServiceAccountSecretAccessKey,
EndpointUrl: s.ApiAddr,
UseSSL: false,
}
},
},
}
server := internaltest.NewMinioServer(t)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ensureServiceAccount(context.Background(), tt.saFunc(server))
if tt.expErrMsg != "" {
require.ErrorContains(t, err, tt.expErrMsg)
return
}
require.NoError(t, err)
})
}
}