mirror of
https://github.com/versity/versitygw.git
synced 2026-09-01 13:46:55 +00:00
feat: add AWS-compatible standalone IAM service
Closes #1640 Add a standalone AWS IAM Query API implementation for managing IAM users through standard AWS SDKs and the AWS CLI. Server usage Start the IAM server with internal file-backed storage: mkdir -p /tmp/versitygw-iam ./versitygw --port 127.0.0.1:7070 --access user --secret pass iam --dir /tmp/versitygw-iam Start the IAM server with Vault KV v2 storage using AppRole: VGW_IAM_VAULT_ROLE_SECRET=<role-secret> ./versitygw --port 127.0.0.1:7070 --access user --secret pass iam --vault-endpoint-url http://127.0.0.1:8200 --vault-auth-method approle --vault-role-id <role-id> --vault-mount-path kv --vault-secret-storage-path iam Vault authentication also supports root tokens, separate authentication and secret-storage namespaces, custom mount paths, server certificate validation, and mutual TLS client certificates. Configure the AWS CLI credentials used by the IAM server: export AWS_ACCESS_KEY_ID=user export AWS_SECRET_ACCESS_KEY=pass export AWS_DEFAULT_REGION=us-east-1 Implemented IAM actions CreateUser creates an IAM user with an AWS-compatible ARN, generated AIDA user ID, creation timestamp, optional path, and tags. It validates usernames, paths, tag limits, reserved tag prefixes, duplicate tag keys, and existing users. aws --endpoint-url http://127.0.0.1:7070 iam create-user --user-name bob aws --endpoint-url http://127.0.0.1:7070 iam create-user --user-name bob --path /engineering/ --tags Key=team,Value=storage GetUser returns a stored user or the root identity when requested without a username through the IAM Query API. aws --endpoint-url http://127.0.0.1:7070 iam get-user --user-name bob ListUsers returns users in deterministic username order and supports path filtering, marker-based pagination, and MaxItems limits. aws --endpoint-url http://127.0.0.1:7070 iam list-users aws --endpoint-url http://127.0.0.1:7070 iam list-users --path-prefix /engineering/ --max-items 100 UpdateUser updates the username and/or path, recalculates the user ARN, and rejects conflicts with existing users. aws --endpoint-url http://127.0.0.1:7070 iam update-user --user-name bob --new-user-name robert --new-path /platform/ DeleteUser permanently removes an IAM user and returns AWS-compatible errors for missing users. aws --endpoint-url http://127.0.0.1:7070 iam delete-user --user-name robert IAM protocol and authentication - Support the AWS IAM Query protocol version 2010-05-08 over GET and POST form requests. - Return AWS-compatible XML responses, error documents, status codes, request IDs, user metadata, and pagination fields. - Authenticate root credentials with AWS Signature Version 4 for the IAM service in us-east-1. - Support both Authorization-header and query-string SigV4 authentication. - Validate credential scope, signed headers, timestamps, clock skew, content length, signatures, and unsupported signature or session-token modes. - Add IAM-specific validation and error mapping for malformed requests, invalid actions, duplicate entities, missing users, throttling, and internal failures. Storage implementations - Add an internal JSON-backed store using iam.json and iam.json.backup with atomic temporary-file replacement, concurrent access protection, stable ordering, pagination, and persistence across restarts. - Add a Vault KV v2 store with one secret per user, CAS-based duplicate protection, permanent deletion, AppRole reauthentication, namespace support, configurable authentication and KV mounts, root-token authentication, and TLS/mTLS configuration. - Introduce a common Storer interface and require exactly one storage backend to be configured. Server and embedding support - Register the new `versitygw iam` command with environment-variable and CLI configuration for both storage backends. - Add `embedgw.RunIAMAPI` and `IAMConfig` for embedding the IAM service in Go applications. Gateway-level internal packages - Add `internal/iamstore` as a reusable generic file-backed IAM persistence engine and migrate the existing gateway internal IAM service to it. - Add `internal/sigv4auth` for shared SigV4 header and presigned-query parsing, canonical request generation, signature verification, and structured authentication errors. - Refactor the S3 authentication paths to use the shared SigV4 implementation while preserving S3-specific error responses. - Add `internal/httpctx` for shared Fiber context keys and AWS-style request ID handling. - Add `internal/routekit` for shared query, form, and header route matchers. - Add `internal/netutil` for reusable certificate storage, hostname-aware listeners, multi-address serving, TLS listeners, and UNIX socket handling. - Update the custom SigV4 signer to honor an explicitly supplied signed-header list so unrelated headers do not alter IAM signatures. Testing and CI - Add AWS IAM SDK-based integration coverage for all supported user actions, header authentication, query authentication, validation, errors, filtering, and pagination. - Split standalone IAM tests into `versitygw test iam` and retain existing gateway IAM tests under `versitygw test gw-iam`. - Add unit coverage for controllers, authentication, routing, storage, embedding, listeners, request matching, persistence, and signing behavior. - Add `runiamtests.sh` to exercise internal storage over HTTP and HTTPS plus Vault storage through AppRole. - Add a dedicated IAM functional-test workflow with a Vault service and merged runtime coverage reporting. - Include the IAM test runner in shellcheck and add the AWS IAM SDK dependency.
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
"github.com/versity/versitygw/internal/iamstore"
|
||||
)
|
||||
|
||||
const (
|
||||
iamFile = "iam.json"
|
||||
iamBackupFile = "iam.json.backup"
|
||||
)
|
||||
|
||||
type InternalStore struct {
|
||||
sync.RWMutex
|
||||
engine *iamstore.Engine[iamConfig]
|
||||
}
|
||||
|
||||
var _ Storer = (*InternalStore)(nil)
|
||||
|
||||
func NewInternal(dir string) (Storer, error) {
|
||||
engine, err := iamstore.New(dir, iamFile, iamBackupFile, defaultIAMConfig(), normalizeIAMConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &InternalStore{engine: engine}, nil
|
||||
}
|
||||
|
||||
type iamConfig struct {
|
||||
Users map[string]types.User `json:"users"`
|
||||
}
|
||||
|
||||
func defaultIAMConfig() iamConfig {
|
||||
return iamConfig{Users: map[string]types.User{}}
|
||||
}
|
||||
|
||||
func normalizeIAMConfig(conf *iamConfig) {
|
||||
if conf.Users == nil {
|
||||
conf.Users = make(map[string]types.User)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InternalStore) CreateUser(_ context.Context, user types.User) (*types.User, error) {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
|
||||
conf, err := s.engine.ParseIAM(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, ok := conf.Users[user.UserName]; ok {
|
||||
return nil, iamerr.EntityAlreadyExistsUser(user.UserName)
|
||||
}
|
||||
for _, existing := range conf.Users {
|
||||
if existing.UserID == user.UserID {
|
||||
return nil, ErrUserIDAlreadyExists
|
||||
}
|
||||
}
|
||||
|
||||
conf.Users[user.UserName] = user
|
||||
return json.Marshal(conf)
|
||||
}); err != nil {
|
||||
return nil, unwrapAPIError(err)
|
||||
}
|
||||
|
||||
return cloneUser(user), nil
|
||||
}
|
||||
|
||||
func (s *InternalStore) DeleteUser(_ context.Context, username string) error {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
|
||||
conf, err := s.engine.ParseIAM(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, ok := conf.Users[username]; !ok {
|
||||
return nil, iamerr.NoSuchEntityUser(username)
|
||||
}
|
||||
|
||||
delete(conf.Users, username)
|
||||
return json.Marshal(conf)
|
||||
})
|
||||
return unwrapAPIError(err)
|
||||
}
|
||||
|
||||
func (s *InternalStore) GetUser(_ context.Context, username string) (*types.User, error) {
|
||||
s.RLock()
|
||||
defer s.RUnlock()
|
||||
|
||||
conf, err := s.engine.GetIAM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, ok := conf.Users[username]
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityUser(username)
|
||||
}
|
||||
|
||||
return cloneUser(user), nil
|
||||
}
|
||||
|
||||
func (s *InternalStore) ListUsers(_ context.Context, input ListUsersInput) (*ListUsersOutput, error) {
|
||||
s.RLock()
|
||||
defer s.RUnlock()
|
||||
|
||||
conf, err := s.engine.GetIAM()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users := make([]types.User, 0, len(conf.Users))
|
||||
for _, user := range conf.Users {
|
||||
if input.PathPrefix != "" && !strings.HasPrefix(user.Path, input.PathPrefix) {
|
||||
continue
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
sort.Slice(users, func(i, j int) bool {
|
||||
return users[i].UserName < users[j].UserName
|
||||
})
|
||||
|
||||
start := 0
|
||||
if input.Marker != "" {
|
||||
start = len(users)
|
||||
for i, user := range users {
|
||||
if user.UserName == input.Marker {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
users = users[start:]
|
||||
|
||||
limit := len(users)
|
||||
if input.MaxItems > 0 && int(input.MaxItems) < limit {
|
||||
limit = int(input.MaxItems)
|
||||
}
|
||||
|
||||
out := &ListUsersOutput{
|
||||
Users: make([]types.User, limit),
|
||||
}
|
||||
copy(out.Users, users[:limit])
|
||||
if limit < len(users) {
|
||||
out.IsTruncated = true
|
||||
out.Marker = out.Users[limit-1].UserName
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *InternalStore) UpdateUser(_ context.Context, input UpdateUserInput) (*types.User, error) {
|
||||
s.Lock()
|
||||
defer s.Unlock()
|
||||
|
||||
var updated types.User
|
||||
if err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
|
||||
conf, err := s.engine.ParseIAM(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, ok := conf.Users[input.UserName]
|
||||
if !ok {
|
||||
return nil, iamerr.NoSuchEntityUser(input.UserName)
|
||||
}
|
||||
|
||||
finalName := user.UserName
|
||||
if input.NewUserName != "" {
|
||||
finalName = input.NewUserName
|
||||
}
|
||||
if finalName != input.UserName {
|
||||
if _, ok := conf.Users[finalName]; ok {
|
||||
return nil, iamerr.EntityAlreadyExistsUser(finalName)
|
||||
}
|
||||
}
|
||||
|
||||
if input.NewPath != "" {
|
||||
user.Path = input.NewPath
|
||||
}
|
||||
if input.NewUserName != "" {
|
||||
user.UserName = input.NewUserName
|
||||
}
|
||||
if input.NewArn != "" {
|
||||
user.Arn = input.NewArn
|
||||
}
|
||||
|
||||
if user.UserName != input.UserName {
|
||||
delete(conf.Users, input.UserName)
|
||||
}
|
||||
conf.Users[user.UserName] = user
|
||||
updated = user
|
||||
|
||||
return json.Marshal(conf)
|
||||
}); err != nil {
|
||||
return nil, unwrapAPIError(err)
|
||||
}
|
||||
|
||||
return cloneUser(updated), nil
|
||||
}
|
||||
|
||||
func cloneUser(user types.User) *types.User {
|
||||
cloned := user
|
||||
cloned.Tags = slices.Clone(user.Tags)
|
||||
return &cloned
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserIDAlreadyExists = errors.New("iamapi: user id already exists")
|
||||
)
|
||||
|
||||
type ListUsersInput struct {
|
||||
PathPrefix string
|
||||
Marker string
|
||||
MaxItems int32
|
||||
}
|
||||
|
||||
type ListUsersOutput struct {
|
||||
Users []types.User
|
||||
IsTruncated bool
|
||||
Marker string
|
||||
}
|
||||
|
||||
type UpdateUserInput struct {
|
||||
UserName string
|
||||
NewPath string
|
||||
NewUserName string
|
||||
NewArn string
|
||||
}
|
||||
|
||||
// Storer is the IAM API storage backend contract.
|
||||
type Storer interface {
|
||||
CreateUser(ctx context.Context, user types.User) (*types.User, error)
|
||||
DeleteUser(ctx context.Context, username string) error
|
||||
GetUser(ctx context.Context, username string) (*types.User, error)
|
||||
ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error)
|
||||
UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error)
|
||||
}
|
||||
|
||||
func unwrapAPIError(err error) error {
|
||||
var apiErr iamerr.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Dir string
|
||||
Vault VaultConfig
|
||||
}
|
||||
|
||||
func New(cfg Config) (Storer, error) {
|
||||
dir := strings.TrimSpace(cfg.Dir)
|
||||
vaultEndpoint := strings.TrimSpace(cfg.Vault.EndpointURL)
|
||||
|
||||
selected := make([]string, 0, 2)
|
||||
if dir != "" {
|
||||
selected = append(selected, "dir")
|
||||
}
|
||||
if vaultEndpoint != "" {
|
||||
selected = append(selected, "vault")
|
||||
}
|
||||
|
||||
switch len(selected) {
|
||||
case 0:
|
||||
return nil, fmt.Errorf("no IAM storer config specified")
|
||||
case 1:
|
||||
default:
|
||||
return nil, fmt.Errorf("multiple IAM storer configs specified: %s", strings.Join(selected, ", "))
|
||||
}
|
||||
|
||||
switch {
|
||||
case dir != "":
|
||||
store, err := NewInternal(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init internal IAM storer: %w", err)
|
||||
}
|
||||
return store, nil
|
||||
case vaultEndpoint != "":
|
||||
store, err := NewVault(cfg.Vault)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init vault IAM storer: %w", err)
|
||||
}
|
||||
return store, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("no IAM storer config specified")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
)
|
||||
|
||||
func TestNewRequiresConfig(t *testing.T) {
|
||||
_, err := New(Config{})
|
||||
if err == nil {
|
||||
t.Fatal("New returned nil error without a storer config")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no IAM storer config specified") {
|
||||
t.Fatalf("error = %q, want missing storer config", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCreatesInternalStore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
_, err := New(Config{Dir: dir})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "iam.json")); err != nil {
|
||||
t.Fatalf("stat initialized IAM file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsMultipleConfigs(t *testing.T) {
|
||||
_, err := New(Config{
|
||||
Dir: t.TempDir(),
|
||||
Vault: VaultConfig{
|
||||
EndpointURL: "https://vault.example.test",
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("New returned nil error with multiple storer configs")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "multiple IAM storer configs specified") {
|
||||
t.Fatalf("error = %q, want multiple storer configs", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewVaultRequiresAuth(t *testing.T) {
|
||||
_, err := New(Config{
|
||||
Vault: VaultConfig{
|
||||
EndpointURL: "https://vault.example.test",
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("New returned nil error for vault storer without auth credentials")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "vault authentication requires either roleid/rolesecret or root token") {
|
||||
t.Fatalf("error = %q, want auth required error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalStoreUserCRUDAndPagination(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
store, err := NewInternal(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewInternal: %v", err)
|
||||
}
|
||||
|
||||
created := time.Date(2026, 6, 23, 18, 0, 0, 0, time.UTC)
|
||||
users := []types.User{
|
||||
{
|
||||
Path: "/engineering/",
|
||||
UserName: "alice",
|
||||
UserID: "AIDA22222222222222222",
|
||||
Arn: "arn:aws:iam::000000000000:user/engineering/alice",
|
||||
CreateDate: created,
|
||||
Tags: []types.Tag{
|
||||
{Key: "env", Value: "test"},
|
||||
{Key: "empty", Value: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: "/engineering/platform/",
|
||||
UserName: "bob",
|
||||
UserID: "AIDA33333333333333333",
|
||||
Arn: "arn:aws:iam::000000000000:user/engineering/platform/bob",
|
||||
CreateDate: created.Add(time.Second),
|
||||
},
|
||||
{
|
||||
Path: "/ops/",
|
||||
UserName: "carol",
|
||||
UserID: "AIDA44444444444444444",
|
||||
Arn: "arn:aws:iam::000000000000:user/ops/carol",
|
||||
CreateDate: created.Add(2 * time.Second),
|
||||
},
|
||||
}
|
||||
for _, user := range users {
|
||||
if _, err := store.CreateUser(ctx, user); err != nil {
|
||||
t.Fatalf("CreateUser(%s): %v", user.UserName, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := store.CreateUser(ctx, users[0]); !errors.Is(err, iamerr.EntityAlreadyExistsUser("alice")) {
|
||||
t.Fatalf("CreateUser duplicate err = %v, want EntityAlreadyExists", err)
|
||||
}
|
||||
duplicateID := users[2]
|
||||
duplicateID.UserName = "dave"
|
||||
if _, err := store.CreateUser(ctx, duplicateID); !errors.Is(err, ErrUserIDAlreadyExists) {
|
||||
t.Fatalf("CreateUser duplicate id err = %v, want ErrUserIDAlreadyExists", err)
|
||||
}
|
||||
|
||||
got, err := store.GetUser(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if got.UserName != "alice" || got.UserID != users[0].UserID {
|
||||
t.Fatalf("GetUser = %#v, want alice with stable id", got)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Tags, users[0].Tags) {
|
||||
t.Fatalf("GetUser tags = %#v, want %#v", got.Tags, users[0].Tags)
|
||||
}
|
||||
|
||||
page1, err := store.ListUsers(ctx, ListUsersInput{PathPrefix: "/engineering/", MaxItems: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers page1: %v", err)
|
||||
}
|
||||
if len(page1.Users) != 1 || page1.Users[0].UserName != "alice" || !page1.IsTruncated || page1.Marker != "alice" {
|
||||
t.Fatalf("page1 = %#v, want truncated alice page", page1)
|
||||
}
|
||||
if !reflect.DeepEqual(page1.Users[0].Tags, users[0].Tags) {
|
||||
t.Fatalf("ListUsers tags = %#v, want %#v", page1.Users[0].Tags, users[0].Tags)
|
||||
}
|
||||
|
||||
page2, err := store.ListUsers(ctx, ListUsersInput{PathPrefix: "/engineering/", Marker: page1.Marker, MaxItems: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers page2: %v", err)
|
||||
}
|
||||
if len(page2.Users) != 1 || page2.Users[0].UserName != "bob" || page2.IsTruncated {
|
||||
t.Fatalf("page2 = %#v, want final bob page", page2)
|
||||
}
|
||||
|
||||
updated, err := store.UpdateUser(ctx, UpdateUserInput{
|
||||
UserName: "alice",
|
||||
NewPath: "/ops/",
|
||||
NewUserName: "zoe",
|
||||
NewArn: "arn:aws:iam::000000000000:user/ops/zoe",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
if updated.UserName != "zoe" || updated.Path != "/ops/" || updated.Arn != "arn:aws:iam::000000000000:user/ops/zoe" {
|
||||
t.Fatalf("updated = %#v, want renamed/path-updated user", updated)
|
||||
}
|
||||
if updated.UserID != users[0].UserID || !updated.CreateDate.Equal(users[0].CreateDate) {
|
||||
t.Fatalf("updated identity changed: %#v", updated)
|
||||
}
|
||||
if !reflect.DeepEqual(updated.Tags, users[0].Tags) {
|
||||
t.Fatalf("updated tags = %#v, want %#v", updated.Tags, users[0].Tags)
|
||||
}
|
||||
if _, err := store.GetUser(ctx, "alice"); !errors.Is(err, iamerr.NoSuchEntityUser("alice")) {
|
||||
t.Fatalf("GetUser old name err = %v, want NoSuchEntity", err)
|
||||
}
|
||||
if _, err := store.UpdateUser(ctx, UpdateUserInput{UserName: "zoe", NewUserName: "bob"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("bob")) {
|
||||
t.Fatalf("UpdateUser duplicate err = %v, want EntityAlreadyExists", err)
|
||||
}
|
||||
|
||||
reopened, err := NewInternal(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen NewInternal: %v", err)
|
||||
}
|
||||
reopenedUser, err := reopened.GetUser(ctx, "zoe")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser after reopen: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(reopenedUser.Tags, users[0].Tags) {
|
||||
t.Fatalf("reopened tags = %#v, want %#v", reopenedUser.Tags, users[0].Tags)
|
||||
}
|
||||
|
||||
if err := reopened.DeleteUser(ctx, "zoe"); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
if err := reopened.DeleteUser(ctx, "zoe"); !errors.Is(err, iamerr.NoSuchEntityUser("zoe")) {
|
||||
t.Fatalf("DeleteUser missing err = %v, want NoSuchEntity", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
vault "github.com/hashicorp/vault-client-go"
|
||||
"github.com/hashicorp/vault-client-go/schema"
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
)
|
||||
|
||||
const vaultRequestTimeout = 10 * time.Second
|
||||
|
||||
// VaultConfig holds all configuration options for the Vault-backed IAM storer.
|
||||
type VaultConfig struct {
|
||||
EndpointURL string
|
||||
Namespace string
|
||||
SecretStoragePath string
|
||||
SecretStorageNamespace string
|
||||
AuthMethod string
|
||||
AuthNamespace string
|
||||
MountPath string
|
||||
RootToken string
|
||||
RoleID string
|
||||
RoleSecret string
|
||||
ServerCert string
|
||||
ClientCert string
|
||||
ClientCertKey string
|
||||
}
|
||||
|
||||
// VaultStore is a Vault KV v2-backed implementation of Storer.
|
||||
type VaultStore struct {
|
||||
client *vault.Client
|
||||
authReqOpts []vault.RequestOption
|
||||
kvReqOpts []vault.RequestOption
|
||||
secretStoragePath string
|
||||
creds schema.AppRoleLoginRequest
|
||||
}
|
||||
|
||||
var _ Storer = (*VaultStore)(nil)
|
||||
|
||||
func NewVault(cfg VaultConfig) (Storer, error) {
|
||||
opts := []vault.ClientOption{
|
||||
vault.WithAddress(strings.TrimSpace(cfg.EndpointURL)),
|
||||
vault.WithRequestTimeout(vaultRequestTimeout),
|
||||
}
|
||||
|
||||
serverCert := strings.TrimSpace(cfg.ServerCert)
|
||||
clientCert := strings.TrimSpace(cfg.ClientCert)
|
||||
clientCertKey := strings.TrimSpace(cfg.ClientCertKey)
|
||||
|
||||
if serverCert != "" {
|
||||
tls := vault.TLSConfiguration{}
|
||||
tls.ServerCertificate.FromBytes = []byte(serverCert)
|
||||
if clientCert != "" {
|
||||
if clientCertKey == "" {
|
||||
return nil, fmt.Errorf("client certificate and client certificate key should both be specified")
|
||||
}
|
||||
tls.ClientCertificate.FromBytes = []byte(clientCert)
|
||||
tls.ClientCertificateKey.FromBytes = []byte(clientCertKey)
|
||||
}
|
||||
opts = append(opts, vault.WithTLS(tls))
|
||||
}
|
||||
|
||||
client, err := vault.New(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init vault client: %w", err)
|
||||
}
|
||||
|
||||
authMethod := strings.TrimSpace(cfg.AuthMethod)
|
||||
mountPath := strings.TrimSpace(cfg.MountPath)
|
||||
|
||||
authReqOpts := []vault.RequestOption{}
|
||||
if authMethod != "" {
|
||||
authReqOpts = append(authReqOpts, vault.WithMountPath(authMethod))
|
||||
}
|
||||
|
||||
kvReqOpts := []vault.RequestOption{}
|
||||
if mountPath != "" {
|
||||
kvReqOpts = append(kvReqOpts, vault.WithMountPath(mountPath))
|
||||
}
|
||||
|
||||
// Resolve namespaces: specific namespace overrides the generic fallback.
|
||||
authNS := strings.TrimSpace(cfg.AuthNamespace)
|
||||
secretNS := strings.TrimSpace(cfg.SecretStorageNamespace)
|
||||
fallback := strings.TrimSpace(cfg.Namespace)
|
||||
if authNS == "" {
|
||||
authNS = fallback
|
||||
}
|
||||
if secretNS == "" {
|
||||
secretNS = fallback
|
||||
}
|
||||
|
||||
rootToken := strings.TrimSpace(cfg.RootToken)
|
||||
roleID := strings.TrimSpace(cfg.RoleID)
|
||||
roleSecret := strings.TrimSpace(cfg.RoleSecret)
|
||||
|
||||
// AppRole tokens are namespace-scoped; cross-namespace use requires a root token.
|
||||
if rootToken == "" && authNS != "" && secretNS != "" && authNS != secretNS {
|
||||
return nil, fmt.Errorf(
|
||||
"approle tokens are namespace scoped. auth namespace %q and secret storage namespace %q differ. "+
|
||||
"use the same namespace or authenticate with a root token",
|
||||
authNS, secretNS,
|
||||
)
|
||||
}
|
||||
|
||||
if rootToken == "" && authNS != "" {
|
||||
authReqOpts = append(authReqOpts, vault.WithNamespace(authNS))
|
||||
}
|
||||
if secretNS != "" {
|
||||
kvReqOpts = append(kvReqOpts, vault.WithNamespace(secretNS))
|
||||
}
|
||||
|
||||
creds := schema.AppRoleLoginRequest{
|
||||
RoleId: roleID,
|
||||
SecretId: roleSecret,
|
||||
}
|
||||
|
||||
switch {
|
||||
case rootToken != "":
|
||||
if err := client.SetToken(rootToken); err != nil {
|
||||
return nil, fmt.Errorf("root token authentication failure: %w", err)
|
||||
}
|
||||
case roleID != "":
|
||||
if roleSecret == "" {
|
||||
return nil, fmt.Errorf("role id and role secret must both be specified")
|
||||
}
|
||||
resp, err := client.Auth.AppRoleLogin(context.Background(), creds, authReqOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("approle authentication failure: %w", err)
|
||||
}
|
||||
if err := client.SetToken(resp.Auth.ClientToken); err != nil {
|
||||
return nil, fmt.Errorf("approle authentication set token failure: %w", err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("vault authentication requires either roleid/rolesecret or root token")
|
||||
}
|
||||
|
||||
secretStoragePath := strings.TrimSpace(cfg.SecretStoragePath)
|
||||
if secretStoragePath == "" {
|
||||
secretStoragePath = "iam"
|
||||
}
|
||||
|
||||
return &VaultStore{
|
||||
client: client,
|
||||
authReqOpts: authReqOpts,
|
||||
kvReqOpts: kvReqOpts,
|
||||
secretStoragePath: secretStoragePath,
|
||||
creds: creds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// reAuthIfNeeded attempts AppRole re-authentication when vault returns 403.
|
||||
// It returns nil only when the original error was nil or re-auth succeeded.
|
||||
func (s *VaultStore) reAuthIfNeeded(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !vault.IsErrorStatus(err, http.StatusForbidden) {
|
||||
return err
|
||||
}
|
||||
resp, authErr := s.client.Auth.AppRoleLogin(context.Background(), s.creds, s.authReqOpts...)
|
||||
if authErr != nil {
|
||||
return fmt.Errorf("vault re-authentication failure: %w", authErr)
|
||||
}
|
||||
if err := s.client.SetToken(resp.Auth.ClientToken); err != nil {
|
||||
return fmt.Errorf("vault re-authentication set token failure: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User, error) {
|
||||
userMap, err := userToVaultMap(user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serialize user: %w", err)
|
||||
}
|
||||
|
||||
path := s.secretStoragePath + "/" + user.UserName
|
||||
req := schema.KvV2WriteRequest{
|
||||
Data: map[string]any{user.UserName: userMap},
|
||||
Options: map[string]any{
|
||||
"cas": 0,
|
||||
},
|
||||
}
|
||||
|
||||
_, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "check-and-set") {
|
||||
return nil, iamerr.EntityAlreadyExistsUser(user.UserName)
|
||||
}
|
||||
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
||||
return nil, reauthErr
|
||||
}
|
||||
// retry once after re-auth
|
||||
_, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "check-and-set") {
|
||||
return nil, iamerr.EntityAlreadyExistsUser(user.UserName)
|
||||
}
|
||||
if vault.IsErrorStatus(err, http.StatusForbidden) {
|
||||
return nil, fmt.Errorf("vault 403 permission denied on path %q. check KV mount path and policy. original: %w", path, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cloneUser(user), nil
|
||||
}
|
||||
|
||||
func (s *VaultStore) DeleteUser(ctx context.Context, username string) error {
|
||||
if _, err := s.GetUser(ctx, username); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteByPath(username)
|
||||
}
|
||||
|
||||
func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, error) {
|
||||
path := s.secretStoragePath + "/" + username
|
||||
resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
||||
if err != nil {
|
||||
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
||||
return nil, iamerr.NoSuchEntityUser(username)
|
||||
}
|
||||
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
||||
return nil, reauthErr
|
||||
}
|
||||
resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
||||
if err != nil {
|
||||
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
||||
return nil, iamerr.NoSuchEntityUser(username)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
user, err := parseVaultUser(resp.Data.Data, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cloneUser(user), nil
|
||||
}
|
||||
|
||||
func (s *VaultStore) ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error) {
|
||||
resp, err := s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...)
|
||||
if err != nil {
|
||||
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
||||
return &ListUsersOutput{Users: []types.User{}}, nil
|
||||
}
|
||||
reauthErr := s.reAuthIfNeeded(err)
|
||||
if reauthErr != nil {
|
||||
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
||||
return &ListUsersOutput{Users: []types.User{}}, nil
|
||||
}
|
||||
return nil, reauthErr
|
||||
}
|
||||
resp, err = s.client.Secrets.KvV2List(context.Background(), s.secretStoragePath, s.kvReqOpts...)
|
||||
if err != nil {
|
||||
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
||||
return &ListUsersOutput{Users: []types.User{}}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
users := make([]types.User, 0, len(resp.Data.Keys))
|
||||
for _, key := range resp.Data.Keys {
|
||||
user, err := s.GetUser(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.PathPrefix != "" && !strings.HasPrefix(user.Path, input.PathPrefix) {
|
||||
continue
|
||||
}
|
||||
users = append(users, *user)
|
||||
}
|
||||
|
||||
sort.Slice(users, func(i, j int) bool {
|
||||
return users[i].UserName < users[j].UserName
|
||||
})
|
||||
|
||||
start := 0
|
||||
if input.Marker != "" {
|
||||
start = len(users)
|
||||
for i, user := range users {
|
||||
if user.UserName == input.Marker {
|
||||
start = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
users = users[start:]
|
||||
|
||||
limit := len(users)
|
||||
if input.MaxItems > 0 && int(input.MaxItems) < limit {
|
||||
limit = int(input.MaxItems)
|
||||
}
|
||||
|
||||
out := &ListUsersOutput{
|
||||
Users: make([]types.User, limit),
|
||||
}
|
||||
copy(out.Users, users[:limit])
|
||||
if limit < len(users) {
|
||||
out.IsTruncated = true
|
||||
out.Marker = out.Users[limit-1].UserName
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error) {
|
||||
user, err := s.GetUser(ctx, input.UserName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
finalName := user.UserName
|
||||
if input.NewUserName != "" {
|
||||
finalName = input.NewUserName
|
||||
}
|
||||
|
||||
if finalName != input.UserName {
|
||||
existing, err := s.GetUser(ctx, finalName)
|
||||
if err != nil && !errors.Is(err, iamerr.NoSuchEntityUser(finalName)) {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, iamerr.EntityAlreadyExistsUser(finalName)
|
||||
}
|
||||
}
|
||||
|
||||
if input.NewPath != "" {
|
||||
user.Path = input.NewPath
|
||||
}
|
||||
if input.NewUserName != "" {
|
||||
user.UserName = input.NewUserName
|
||||
}
|
||||
if input.NewArn != "" {
|
||||
user.Arn = input.NewArn
|
||||
}
|
||||
|
||||
if user.UserName != input.UserName {
|
||||
// Create at new path first to detect conflicts before deleting the old entry.
|
||||
if _, err := s.CreateUser(ctx, *user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.deleteByPath(input.UserName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Delete all versions then re-create so CAS=0 succeeds.
|
||||
if err := s.deleteByPath(input.UserName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, *user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return cloneUser(*user), nil
|
||||
}
|
||||
|
||||
// deleteByPath permanently removes a secret and all its versions without
|
||||
// checking for existence first.
|
||||
func (s *VaultStore) deleteByPath(username string) error {
|
||||
path := s.secretStoragePath + "/" + username
|
||||
_, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...)
|
||||
if err != nil {
|
||||
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
||||
return reauthErr
|
||||
}
|
||||
_, err = s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine")
|
||||
|
||||
// userToVaultMap round-trips User through JSON to produce a map[string]any
|
||||
// that vault can store without losing type information on read-back.
|
||||
func userToVaultMap(user types.User) (map[string]any, error) {
|
||||
b, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// parseVaultUser reconstructs a User from the raw map[string]any that vault
|
||||
// returns. The outer key is the username.
|
||||
func parseVaultUser(data map[string]any, username string) (types.User, error) {
|
||||
raw, ok := data[username]
|
||||
if !ok {
|
||||
return types.User{}, errInvalidVaultUser
|
||||
}
|
||||
userMap, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return types.User{}, errInvalidVaultUser
|
||||
}
|
||||
b, err := json.Marshal(userMap)
|
||||
if err != nil {
|
||||
return types.User{}, fmt.Errorf("re-marshal vault user: %w", err)
|
||||
}
|
||||
var user types.User
|
||||
if err := json.Unmarshal(b, &user); err != nil {
|
||||
return types.User{}, fmt.Errorf("unmarshal vault user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
Reference in New Issue
Block a user