Files
hashicorp-copywrite[bot] 3df232d1f2 [COMPLIANCE] Add/Update Copyright Headers (#47)
Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2026-05-11 22:39:20 -07:00

251 lines
7.2 KiB
Go

// Copyright IBM Corp. 2025, 2026
// 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
}