chore: update usage of minio admin library (#28)

* chore: update usage of minio admin library
This commit is contained in:
Louis Ruch authored and GitHub committed 2025-01-21 09:45:04 -08:00
1 parent 7ceb652854
commit c9e9377b4a
22 files changed
+1547 -389

No files matched your search

+250
View File
@@ -0,0 +1,250 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package client
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"github.com/hashicorp/boundary-plugin-minio/madmin"
"github.com/minio/minio-go/v7/pkg/signer"
)
const adminPrefix = "/minio/admin/v3"
// AddServiceAccountReq contains the values used by the plugin when creating a minio service account
type AddServiceAccountReq struct {
// Policy to apply to service account
Policy json.RawMessage `json:"policy,omitempty"`
// Name for this access key
Name string `json:"name,omitempty"`
// Description for this access key
Description string `json:"description,omitempty"`
}
// AddServiceAccountResp contains the credentials associated with the added service account
type AddServiceAccountResp struct {
Cred Credential `json:"credentials"`
}
// Credential contains a minio AccessKeyId and SecretAccessKey
type Credential struct {
AccessKeyId string `json:"accessKey,omitempty"`
SecretAccessKey string `json:"secretKey,omitempty"`
}
// MinioError contains the error message from failed admin API requests
type MinioError struct {
Message string `json:"message,omitempty"`
}
// Client is used to access Minio admin API
type Client struct {
endpointUrl *url.URL
accessKeyId string
secretAccessKey string
}
// getOpts - iterate the inbound Options and return a struct
func getOpts(opt ...Option) options {
opts := getDefaultOptions()
for _, o := range opt {
o(&opts)
}
return opts
}
// Option - how options are passed as arguments
type Option func(*options)
// options = how options are represented
type options struct {
withUseSsl bool
}
func getDefaultOptions() options {
return options{}
}
// WithUseSsl provides an Option to use SSL.
func WithUseSsl(b bool) Option {
return func(o *options) {
o.withUseSsl = b
}
}
// New creates a Client that can be used to access Minio admin API
func New(endpointUrl, accessKeyId, secretAccessKey string, opt ...Option) (*Client, error) {
opts := getOpts(opt...)
url, err := madmin.GetEndpointURL(endpointUrl, opts.withUseSsl)
if err != nil {
return nil, err
}
return &Client{endpointUrl: url, accessKeyId: accessKeyId, secretAccessKey: secretAccessKey}, nil
}
// DeleteServiceAccount deletes the provided serviceAccount
func (c *Client) DeleteServiceAccount(ctx context.Context, serviceAccount string) error {
// construct the url
url := c.endpointUrl.JoinPath(adminPrefix, "delete-service-account")
query := url.Query()
query.Set("accessKey", serviceAccount)
url.RawQuery = query.Encode()
// create the request
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url.String(), nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Get sha256 for request header
sum := sha256.Sum256(nil)
req.Header.Set("X-Amz-Content-Sha256", hex.EncodeToString(sum[:]))
// Sign request as documented
// https://min.io/docs/minio/kubernetes/upstream/administration/identity-access-management.html
req = signer.SignV4(*req, c.accessKeyId, c.secretAccessKey, "", "")
// Send the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Check response status code
if resp.StatusCode != http.StatusNoContent {
// expected no content response, unmarshal resp body for error message
respBody, _ := io.ReadAll(resp.Body)
if len(respBody) == 0 {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
errResp := &MinioError{}
if err := json.Unmarshal(respBody, &errResp); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
return errors.New(errResp.Message)
}
return nil
}
// AddServiceAccount adds a service account to the minio server
func (c *Client) AddServiceAccount(ctx context.Context, in AddServiceAccountReq) (Credential, error) {
// construct the url
url := c.endpointUrl.JoinPath(adminPrefix, "add-service-account")
// marshal and encrypt data payload
data, err := json.Marshal(in)
if err != nil {
return Credential{}, err
}
// Minio encrypts streaming data as described https://blog.min.io/frictionless-encryption/
payload, err := madmin.EncryptData(c.secretAccessKey, data)
if err != nil {
return Credential{}, err
}
// create the request
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url.String(), bytes.NewReader(payload))
if err != nil {
return Credential{}, fmt.Errorf("failed to create request: %w", err)
}
// Get sha256 for request header
sum := sha256.Sum256(payload)
req.Header.Set("X-Amz-Content-Sha256", hex.EncodeToString(sum[:]))
req.Header.Set("Content-Type", "application/json")
// Sign request as documented
// https://min.io/docs/minio/kubernetes/upstream/administration/identity-access-management.html
req = signer.SignV4(*req, c.accessKeyId, c.secretAccessKey, "", "")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return Credential{}, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// did not get ok, unmarshal resp body for error message
respBody, _ := io.ReadAll(resp.Body)
if len(respBody) == 0 {
return Credential{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
errResp := &MinioError{}
if err := json.Unmarshal(respBody, &errResp); err != nil {
return Credential{}, fmt.Errorf("failed to parse response: %w", err)
}
return Credential{}, errors.New(errResp.Message)
}
// got resp lets decrypt it
result, err := madmin.DecryptData(c.secretAccessKey, resp.Body)
addResp := &AddServiceAccountResp{}
if err := json.Unmarshal(result, &addResp); err != nil {
return Credential{}, fmt.Errorf("failed to parse response: %w", err)
}
return addResp.Cred, nil
}
// EnsureServiceAccount true if the account is a service account
func (c *Client) EnsureServiceAccount(ctx context.Context, serviceAccount string) error {
// construct the url
url := c.endpointUrl.JoinPath(adminPrefix, "info-service-account")
query := url.Query()
query.Set("accessKey", serviceAccount)
url.RawQuery = query.Encode()
// create the request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// get sha256 for request header
sum := sha256.Sum256(nil)
req.Header.Set("X-Amz-Content-Sha256", hex.EncodeToString(sum[:]))
// sign request as documented
// https://min.io/docs/minio/kubernetes/upstream/administration/identity-access-management.html
req = signer.SignV4(*req, c.accessKeyId, c.secretAccessKey, "", "")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// did not get ok, unmarshal resp body for error message
respBody, _ := io.ReadAll(resp.Body)
if len(respBody) == 0 {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
errResp := &MinioError{}
if err := json.Unmarshal(respBody, &errResp); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
return errors.New(errResp.Message)
}
return nil
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package client
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
t.Parallel()
assert, require := assert.New(t), require.New(t)
type args struct {
endpointUrl string
accessKeyId string
secretAccessKey string
opt []Option
}
tests := []struct {
name string
args args
wantUrl string
wantErr bool
}{
{
name: "fully qualified path",
args: args{endpointUrl: "https://test.com"},
wantErr: true,
},
{
name: "no ssl",
args: args{
endpointUrl: "test.com",
accessKeyId: "username",
secretAccessKey: "password",
},
wantUrl: "http://test.com",
},
{
name: "with ssl",
args: args{
endpointUrl: "test.com",
accessKeyId: "username",
secretAccessKey: "password",
opt: []Option{WithUseSsl(true)},
},
wantUrl: "https://test.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := New(tt.args.endpointUrl, tt.args.accessKeyId, tt.args.secretAccessKey, tt.args.opt...)
if tt.wantErr {
require.Error(err)
assert.Nil(got)
return
}
require.NoError(err)
require.NotNil(got)
assert.Equal(tt.wantUrl, got.endpointUrl.String())
assert.Equal(tt.args.accessKeyId, got.accessKeyId)
assert.Equal(tt.args.secretAccessKey, got.secretAccessKey)
})
}
}
func Test_getOpts(t *testing.T) {
t.Parallel()
t.Run("WithUseSsl", func(t *testing.T) {
testOpts := getDefaultOptions()
assert.Equal(t, false, testOpts.withUseSsl)
testOpts = getOpts()
assert.Equal(t, false, testOpts.withUseSsl)
testOpts = getOpts(WithUseSsl(true))
assert.Equal(t, true, testOpts.withUseSsl)
})
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package client_test
import (
"context"
"testing"
"github.com/hashicorp/boundary-plugin-minio/internal/client"
internaltest "github.com/hashicorp/boundary-plugin-minio/internal/testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestServiceAccount(t *testing.T) {
t.Parallel()
ctx := context.Background()
assert, require := assert.New(t), require.New(t)
server := internaltest.NewMinioServer(t)
c, err := client.New(server.ApiAddr, server.RootUsername, server.RootPassword)
require.NoError(err)
assert.NotNil(c)
creds, err := c.AddServiceAccount(ctx, client.AddServiceAccountReq{})
require.NoError(err)
// We just added service account it should be valid
err = c.EnsureServiceAccount(ctx, creds.AccessKeyId)
require.NoError(err)
err = c.DeleteServiceAccount(ctx, creds.AccessKeyId)
require.NoError(err)
// Now that we have deleted the service account it should be invalid
err = c.EnsureServiceAccount(ctx, creds.AccessKeyId)
require.Error(err)
}
+9 -12
View File
@@ -11,7 +11,7 @@ import (
"testing"
"time"
"github.com/minio/madmin-go/v3"
"github.com/hashicorp/boundary-plugin-minio/internal/client"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/ory/dockertest/v3"
@@ -46,8 +46,8 @@ type MinioServer struct {
// Client is a MinIO SDK client, created with the default service account
// credentials.
Client *minio.Client
// AdminClient is a Madmin client created with the root account credentials.
AdminClient *madmin.AdminClient
// AdminClient is a client created with the root account credentials.
AdminClient *client.Client
}
type Option func(testing.TB, *options)
@@ -165,23 +165,20 @@ func NewMinioServer(t testing.TB, inOpts ...Option) *MinioServer {
}
// Create service account.
acl, err := madmin.NewWithOptions(server.ApiAddr, &madmin.Options{
Creds: credentials.NewStaticV4(server.RootUsername, server.RootPassword, ""),
Secure: false,
})
c, err := client.New(server.ApiAddr, server.RootUsername, server.RootPassword)
require.NoError(t, err)
server.AdminClient = acl
server.AdminClient = c
creds, err := acl.AddServiceAccount(context.Background(), madmin.AddServiceAccountReq{
creds, err := c.AddServiceAccount(context.Background(), client.AddServiceAccountReq{
Name: "Boundary MinIO Plugin Test",
Description: "MinIO credentials for Boundary MinIO plugin testing",
})
require.NoError(t, err)
server.ServiceAccountAccessKeyId = creds.AccessKey
server.ServiceAccountSecretAccessKey = creds.SecretKey
server.ServiceAccountAccessKeyId = creds.AccessKeyId
server.ServiceAccountSecretAccessKey = creds.SecretAccessKey
cl, err := minio.New(server.ApiAddr, &minio.Options{
Creds: credentials.NewStaticV4(creds.AccessKey, creds.SecretKey, ""),
Creds: credentials.NewStaticV4(creds.AccessKeyId, creds.SecretAccessKey, ""),
Secure: false,
})
require.NoError(t, err)