package client import ( "bytes" "context" "crypto/sha256" "crypto/tls" "encoding/hex" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/signer/v4" ) type Config struct { Endpoint string Region string AdminAccessKey string AdminSecretKey string InsecureTLS bool } type Client struct { endpoint *url.URL region string creds aws.Credentials http *http.Client signer *v4.Signer } type Account struct { Access string `xml:"Access"` Secret string `xml:"Secret,omitempty"` Role string `xml:"Role"` UserID int64 `xml:"UserID"` GroupID int64 `xml:"GroupID"` ProjectID int64 `xml:"ProjectID"` } type MutableProps struct { XMLName xml.Name `xml:"MutableProps"` Secret *string `xml:"Secret,omitempty"` Role string `xml:"Role,omitempty"` UserID *int64 `xml:"UserID,omitempty"` GroupID *int64 `xml:"GroupID,omitempty"` ProjectID *int64 `xml:"ProjectID,omitempty"` } type ListUserAccountsResult struct { XMLName xml.Name `xml:"ListUserAccountsResult"` Accounts []Account `xml:"Accounts"` } type ErrorResponse struct { XMLName xml.Name `xml:"Error"` Code string `xml:"Code"` Message string `xml:"Message"` RequestID string `xml:"RequestId"` HostID string `xml:"HostId"` } type APIError struct { Status int Code string Message string RequestID string HostID string RawBody string } func (e *APIError) Error() string { if e.Message == "" { return fmt.Sprintf("admin API status %d: %s", e.Status, e.Code) } return fmt.Sprintf("admin API status %d: %s: %s", e.Status, e.Code, e.Message) } func IsErrorCode(err error, code string) bool { var apiErr *APIError if !errors.As(err, &apiErr) { return false } return apiErr.Code == code } func New(cfg Config) (*Client, error) { if cfg.Endpoint == "" { return nil, fmt.Errorf("endpoint is required") } if cfg.Region == "" { cfg.Region = "us-east-1" } if cfg.AdminAccessKey == "" { return nil, fmt.Errorf("admin access key is required") } if cfg.AdminSecretKey == "" { return nil, fmt.Errorf("admin secret key is required") } endpoint, err := url.Parse(cfg.Endpoint) if err != nil { return nil, fmt.Errorf("parse endpoint: %w", err) } if endpoint.Scheme == "" || endpoint.Host == "" { return nil, fmt.Errorf("endpoint must include scheme and host") } return &Client{ endpoint: endpoint, region: cfg.Region, creds: aws.Credentials{ AccessKeyID: cfg.AdminAccessKey, SecretAccessKey: cfg.AdminSecretKey, }, http: &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureTLS}, //nolint:gosec }, }, signer: v4.NewSigner(), }, nil } func (c *Client) CreateUser(ctx context.Context, account Account) error { body, err := xml.Marshal(account) if err != nil { return fmt.Errorf("marshal create-user request: %w", err) } _, err = c.do(ctx, http.MethodPatch, "/create-user", nil, body) return err } func (c *Client) UpdateUser(ctx context.Context, access string, props MutableProps) error { body, err := xml.Marshal(props) if err != nil { return fmt.Errorf("marshal update-user request: %w", err) } query := url.Values{"access": []string{access}} _, err = c.do(ctx, http.MethodPatch, "/update-user", query, body) return err } func (c *Client) DeleteUser(ctx context.Context, access string) error { query := url.Values{"access": []string{access}} _, err := c.do(ctx, http.MethodPatch, "/delete-user", query, nil) return err } func (c *Client) GetUser(ctx context.Context, access string) (Account, bool, error) { accounts, err := c.ListUsers(ctx) if err != nil { return Account{}, false, err } for _, account := range accounts { if account.Access == access { return account, true, nil } } return Account{}, false, nil } func (c *Client) ListUsers(ctx context.Context) ([]Account, error) { body, err := c.do(ctx, http.MethodPatch, "/list-users", nil, nil) if err != nil { return nil, err } var result ListUserAccountsResult if err := xml.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parse list-users response: %w", err) } return result.Accounts, nil } func (c *Client) do(ctx context.Context, method, path string, query url.Values, body []byte) ([]byte, error) { endpoint := *c.endpoint endpoint.Path = strings.TrimRight(c.endpoint.Path, "/") + path endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("build request: %w", err) } hashedPayload := sha256.Sum256(body) hexPayload := hex.EncodeToString(hashedPayload[:]) req.Header.Set("X-Amz-Content-Sha256", hexPayload) if len(body) > 0 { req.Header.Set("Content-Type", "application/xml") } if err := c.signer.SignHTTP(ctx, c.creds, req, hexPayload, "s3", c.region, time.Now()); err != nil { return nil, fmt.Errorf("sign request: %w", err) } resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("send request: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode >= 400 { return nil, parseError(resp.StatusCode, respBody) } return respBody, nil } func parseError(status int, body []byte) error { var apiErr ErrorResponse if err := xml.Unmarshal(body, &apiErr); err != nil { return &APIError{ Status: status, RawBody: strings.TrimSpace(string(body)), Message: strings.TrimSpace(string(body)), } } if apiErr.Code == "" && apiErr.Message == "" { return &APIError{ Status: status, RawBody: strings.TrimSpace(string(body)), Message: strings.TrimSpace(string(body)), } } return &APIError{ Status: status, Code: apiErr.Code, Message: apiErr.Message, RequestID: apiErr.RequestID, HostID: apiErr.HostID, RawBody: strings.TrimSpace(string(body)), } }