ce7cb702218693d08ea6d83464d2457c9250027c

This commit is contained in:
ms2k 2026-11-12 20:46:28 +09:00
commit 34c5f8963c
5 changed files with 145336 additions and 0 deletions

View file

@ -0,0 +1,246 @@
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)),
}
}

View file

@ -0,0 +1,29 @@
package client
import (
"encoding/xml"
"testing"
)
func TestListUserAccountsResultUnmarshal(t *testing.T) {
body := []byte(`<ListUserAccountsResult>
<Accounts>
<Access>canary</Access>
<Role>user</Role>
<UserID>0</UserID>
<GroupID>0</GroupID>
<ProjectID>0</ProjectID>
</Accounts>
</ListUserAccountsResult>`)
var result ListUserAccountsResult
if err := xml.Unmarshal(body, &result); err != nil {
t.Fatalf("unmarshal list-users response: %v", err)
}
if len(result.Accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(result.Accounts))
}
if result.Accounts[0].Access != "canary" {
t.Fatalf("expected canary account, got %q", result.Accounts[0].Access)
}
}

View file

@ -0,0 +1,44 @@
package client
import (
"context"
"os"
"testing"
)
func TestLiveListUsers(t *testing.T) {
endpoint := os.Getenv("VERSITYGW_ENDPOINT")
accessKey := os.Getenv("VERSITYGW_ADMIN_ACCESS_KEY")
secretKey := os.Getenv("VERSITYGW_ADMIN_SECRET_KEY")
if endpoint == "" || accessKey == "" || secretKey == "" {
t.Skip("VERSITYGW_ENDPOINT, VERSITYGW_ADMIN_ACCESS_KEY, and VERSITYGW_ADMIN_SECRET_KEY are required")
}
api, err := New(Config{
Endpoint: endpoint,
Region: envOrDefault("VERSITYGW_REGION", "us-east-1"),
AdminAccessKey: accessKey,
AdminSecretKey: secretKey,
InsecureTLS: os.Getenv("VERSITYGW_INSECURE_TLS") == "true",
})
if err != nil {
t.Fatalf("new client: %v", err)
}
accounts, err := api.ListUsers(context.Background())
if err != nil {
t.Fatalf("list users: %v", err)
}
for _, account := range accounts {
t.Logf("access=%s role=%s user_id=%d group_id=%d project_id=%d", account.Access, account.Role, account.UserID, account.GroupID, account.ProjectID)
}
}
func envOrDefault(name, fallback string) string {
value := os.Getenv(name)
if value == "" {
return fallback
}
return value
}

View file

@ -0,0 +1,131 @@
package provider
import (
"context"
"os"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/quorate/terraform-provider-versitygw/internal/client"
)
var _ provider.Provider = (*Provider)(nil)
func New() provider.Provider {
return &Provider{}
}
type Provider struct{}
type providerModel struct {
Endpoint types.String `tfsdk:"endpoint"`
Region types.String `tfsdk:"region"`
AdminAccessKey types.String `tfsdk:"admin_access_key"`
AdminSecretKey types.String `tfsdk:"admin_secret_key"`
InsecureTLS types.Bool `tfsdk:"insecure_tls"`
}
func (p *Provider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "versitygw"
}
func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"endpoint": schema.StringAttribute{
Optional: true,
Description: "VersityGW admin API endpoint. May also be set with VERSITYGW_ENDPOINT.",
},
"region": schema.StringAttribute{
Optional: true,
Description: "SigV4 region. Defaults to us-east-1. May also be set with VERSITYGW_REGION.",
},
"admin_access_key": schema.StringAttribute{
Optional: true,
Sensitive: true,
Description: "VersityGW admin/root access key. May also be set with VERSITYGW_ADMIN_ACCESS_KEY.",
},
"admin_secret_key": schema.StringAttribute{
Optional: true,
Sensitive: true,
Description: "VersityGW admin/root secret key. May also be set with VERSITYGW_ADMIN_SECRET_KEY.",
},
"insecure_tls": schema.BoolAttribute{
Optional: true,
Description: "Disable TLS certificate verification for the admin endpoint. May also be set with VERSITYGW_INSECURE_TLS=true.",
},
},
}
}
func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var cfg providerModel
resp.Diagnostics.Append(req.Config.Get(ctx, &cfg)...)
if resp.Diagnostics.HasError() {
return
}
endpoint := stringValue(cfg.Endpoint, os.Getenv("VERSITYGW_ENDPOINT"))
region := stringValue(cfg.Region, os.Getenv("VERSITYGW_REGION"))
if region == "" {
region = "us-east-1"
}
adminAccessKey := stringValue(cfg.AdminAccessKey, os.Getenv("VERSITYGW_ADMIN_ACCESS_KEY"))
adminSecretKey := stringValue(cfg.AdminSecretKey, os.Getenv("VERSITYGW_ADMIN_SECRET_KEY"))
insecureTLS := boolValue(cfg.InsecureTLS, os.Getenv("VERSITYGW_INSECURE_TLS") == "true")
if endpoint == "" {
resp.Diagnostics.AddAttributeError(path.Root("endpoint"), "Missing VersityGW endpoint", "Set endpoint in provider configuration or VERSITYGW_ENDPOINT.")
}
if adminAccessKey == "" {
resp.Diagnostics.AddAttributeError(path.Root("admin_access_key"), "Missing admin access key", "Set admin_access_key in provider configuration or VERSITYGW_ADMIN_ACCESS_KEY.")
}
if adminSecretKey == "" {
resp.Diagnostics.AddAttributeError(path.Root("admin_secret_key"), "Missing admin secret key", "Set admin_secret_key in provider configuration or VERSITYGW_ADMIN_SECRET_KEY.")
}
if resp.Diagnostics.HasError() {
return
}
api, err := client.New(client.Config{
Endpoint: endpoint,
Region: region,
AdminAccessKey: adminAccessKey,
AdminSecretKey: adminSecretKey,
InsecureTLS: insecureTLS,
})
if err != nil {
resp.Diagnostics.AddError("Invalid VersityGW provider configuration", err.Error())
return
}
resp.ResourceData = api
}
func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewUserResource,
}
}
func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource {
return nil
}
func stringValue(value types.String, fallback string) string {
if value.IsNull() || value.IsUnknown() {
return fallback
}
return value.ValueString()
}
func boolValue(value types.Bool, fallback bool) bool {
if value.IsNull() || value.IsUnknown() {
return fallback
}
return value.ValueBool()
}

View file

@ -0,0 +1,290 @@
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/quorate/terraform-provider-versitygw/internal/client"
)
var (
_ resource.Resource = (*UserResource)(nil)
_ resource.ResourceWithConfigure = (*UserResource)(nil)
)
func NewUserResource() resource.Resource {
return &UserResource{}
}
type UserResource struct {
client *client.Client
}
type userModel struct {
ID types.String `tfsdk:"id"`
AccessKeyID types.String `tfsdk:"access_key_id"`
SecretAccessKey types.String `tfsdk:"secret_access_key"`
Role types.String `tfsdk:"role"`
UserID types.Int64 `tfsdk:"user_id"`
GroupID types.Int64 `tfsdk:"group_id"`
ProjectID types.Int64 `tfsdk:"project_id"`
}
func (r *UserResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_user"
}
func (r *UserResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "VersityGW IAM user managed through the VersityGW admin API.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
Description: "User access key ID.",
},
"access_key_id": schema.StringAttribute{
Required: true,
Description: "VersityGW username/access key ID.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"secret_access_key": schema.StringAttribute{
Required: true,
Sensitive: true,
WriteOnly: true,
Description: "VersityGW user secret access key.",
},
"role": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "VersityGW user role: user, userplus, or admin. Defaults to user.",
},
"user_id": schema.Int64Attribute{
Optional: true,
Computed: true,
Description: "Optional POSIX user ID stored on the VersityGW user.",
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
},
"group_id": schema.Int64Attribute{
Optional: true,
Computed: true,
Description: "Optional POSIX group ID stored on the VersityGW user.",
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
},
"project_id": schema.Int64Attribute{
Optional: true,
Computed: true,
Description: "Optional project ID stored on the VersityGW user.",
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
},
},
}
}
func (r *UserResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
api, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError("Unexpected provider data", fmt.Sprintf("Expected *client.Client, got %T", req.ProviderData))
return
}
r.client = api
}
func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan userModel
var config userModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
if !validateUser(plan, config.SecretAccessKey, &resp.Diagnostics) {
return
}
role := valueOrDefault(plan.Role, "user")
account := client.Account{
Access: plan.AccessKeyID.ValueString(),
Secret: config.SecretAccessKey.ValueString(),
Role: role,
UserID: int64OrZero(plan.UserID),
GroupID: int64OrZero(plan.GroupID),
ProjectID: int64OrZero(plan.ProjectID),
}
if err := r.client.CreateUser(ctx, account); err != nil {
if !client.IsErrorCode(err, "XAdminUserExists") {
resp.Diagnostics.AddError("Create VersityGW user failed", err.Error())
return
}
secret := account.Secret
props := client.MutableProps{
Secret: &secret,
Role: account.Role,
UserID: &account.UserID,
GroupID: &account.GroupID,
ProjectID: &account.ProjectID,
}
if err := r.client.UpdateUser(ctx, account.Access, props); err != nil {
resp.Diagnostics.AddError("Adopt existing VersityGW user failed", err.Error())
return
}
}
plan.ID = plan.AccessKeyID
plan.Role = types.StringValue(role)
plan.UserID = types.Int64Value(account.UserID)
plan.GroupID = types.Int64Value(account.GroupID)
plan.ProjectID = types.Int64Value(account.ProjectID)
plan.SecretAccessKey = types.StringNull()
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state userModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
account, found, err := r.client.GetUser(ctx, state.AccessKeyID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Read VersityGW user failed", err.Error())
return
}
if !found {
resp.State.RemoveResource(ctx)
return
}
state.ID = types.StringValue(account.Access)
state.AccessKeyID = types.StringValue(account.Access)
state.Role = types.StringValue(account.Role)
state.UserID = types.Int64Value(account.UserID)
state.GroupID = types.Int64Value(account.GroupID)
state.ProjectID = types.Int64Value(account.ProjectID)
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}
func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan userModel
var state userModel
var config userModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
if !validateUser(plan, config.SecretAccessKey, &resp.Diagnostics) {
return
}
role := valueOrDefault(plan.Role, "user")
secret := config.SecretAccessKey.ValueString()
userID := int64OrZero(plan.UserID)
groupID := int64OrZero(plan.GroupID)
projectID := int64OrZero(plan.ProjectID)
props := client.MutableProps{
Secret: &secret,
Role: role,
UserID: &userID,
GroupID: &groupID,
ProjectID: &projectID,
}
if err := r.client.UpdateUser(ctx, state.AccessKeyID.ValueString(), props); err != nil {
resp.Diagnostics.AddError("Update VersityGW user failed", err.Error())
return
}
plan.ID = plan.AccessKeyID
plan.Role = types.StringValue(role)
plan.UserID = types.Int64Value(userID)
plan.GroupID = types.Int64Value(groupID)
plan.ProjectID = types.Int64Value(projectID)
plan.SecretAccessKey = types.StringNull()
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
func (r *UserResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var state userModel
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}
if err := r.client.DeleteUser(ctx, state.AccessKeyID.ValueString()); err != nil {
resp.Diagnostics.AddError("Delete VersityGW user failed", err.Error())
return
}
}
func validateUser(model userModel, secretAccessKey types.String, diags interface {
AddAttributeError(path.Path, string, string)
}) bool {
ok := true
if model.AccessKeyID.IsNull() || model.AccessKeyID.IsUnknown() || model.AccessKeyID.ValueString() == "" {
diags.AddAttributeError(path.Root("access_key_id"), "Invalid access key ID", "access_key_id must be non-empty.")
ok = false
}
if secretAccessKey.IsNull() || secretAccessKey.IsUnknown() || secretAccessKey.ValueString() == "" {
diags.AddAttributeError(path.Root("secret_access_key"), "Invalid secret access key", "secret_access_key must be non-empty.")
ok = false
}
role := valueOrDefault(model.Role, "user")
if role != "user" && role != "userplus" && role != "admin" {
diags.AddAttributeError(path.Root("role"), "Invalid role", "role must be one of: user, userplus, admin.")
ok = false
}
if isNegative(model.UserID) {
diags.AddAttributeError(path.Root("user_id"), "Invalid user ID", "user_id must be non-negative.")
ok = false
}
if isNegative(model.GroupID) {
diags.AddAttributeError(path.Root("group_id"), "Invalid group ID", "group_id must be non-negative.")
ok = false
}
if isNegative(model.ProjectID) {
diags.AddAttributeError(path.Root("project_id"), "Invalid project ID", "project_id must be non-negative.")
ok = false
}
return ok
}
func valueOrDefault(value types.String, fallback string) string {
if value.IsNull() || value.IsUnknown() || value.ValueString() == "" {
return fallback
}
return value.ValueString()
}
func int64OrZero(value types.Int64) int64 {
if value.IsNull() || value.IsUnknown() {
return 0
}
return value.ValueInt64()
}
func isNegative(value types.Int64) bool {
return !value.IsNull() && !value.IsUnknown() && value.ValueInt64() < 0
}