snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 03:23:42 +05:00
commit 89f5be005d
3997 changed files with 961970 additions and 0 deletions

167
weed/credential/README.md Normal file
View File

@@ -0,0 +1,167 @@
# Credential Store Integration
This document shows how the credential store has been integrated into SeaweedFS's S3 API and IAM API components.
## Quick Start
1. **Generate credential configuration:**
```bash
weed scaffold -config=credential -output=.
```
2. **Edit credential.toml** to enable your preferred store (filer_etc is enabled by default)
3. **Start S3 API server** - it will automatically load credential.toml:
```bash
weed s3 -filer=localhost:8888
```
## Integration Overview
The credential store provides a pluggable backend for storing S3 identities and credentials, supporting:
- **Filer-based storage** (filer_etc) - Uses existing filer storage (default)
- **PostgreSQL** - Shared database for multiple servers
- **Memory** - In-memory storage for testing
## Configuration
### Using credential.toml
Generate the configuration template:
```bash
weed scaffold -config=credential
```
This creates a `credential.toml` file with all available options. The filer_etc store is enabled by default:
```toml
# Filer-based credential store (default, uses existing filer storage)
[credential.filer_etc]
enabled = true
# PostgreSQL credential store (recommended for multi-node deployments)
[credential.postgres]
enabled = false
hostname = "localhost"
port = 5432
username = "seaweedfs"
password = "your_password"
database = "seaweedfs"
# Memory credential store (for testing only, data is lost on restart)
[credential.memory]
enabled = false
```
The credential.toml file is automatically loaded from these locations (in priority order):
- `./credential.toml`
- `$HOME/.seaweedfs/credential.toml`
- `/etc/seaweedfs/credential.toml`
### Server Configuration
Both S3 API and IAM API servers automatically load credential.toml during startup. No additional configuration is required.
## Usage Examples
### Filer-based Store (Default)
```toml
[credential.filer_etc]
enabled = true
```
This uses the existing filer storage and is compatible with current deployments.
### PostgreSQL Store
```toml
[credential.postgres]
enabled = true
hostname = "localhost"
port = 5432
username = "seaweedfs"
password = "your_password"
database = "seaweedfs"
schema = "public"
sslmode = "disable"
table_prefix = "sw_"
connection_max_idle = 10
connection_max_open = 100
connection_max_lifetime_seconds = 3600
```
### Memory Store (Testing)
```toml
[credential.memory]
enabled = true
```
## Environment Variables
All credential configuration can be overridden with environment variables:
```bash
# Override PostgreSQL password
export WEED_CREDENTIAL_POSTGRES_PASSWORD=secret
# Override PostgreSQL hostname
export WEED_CREDENTIAL_POSTGRES_HOSTNAME=db.example.com
# Enable/disable stores
export WEED_CREDENTIAL_FILER_ETC_ENABLED=true
```
Rules:
- Prefix with `WEED_CREDENTIAL_`
- Convert to uppercase
- Replace `.` with `_`
## Implementation Details
Components automatically load credential configuration during startup:
```go
// Server initialization
if credConfig, err := credential.LoadCredentialConfiguration(); err == nil && credConfig != nil {
credentialManager, err := credential.NewCredentialManager(
credConfig.Store,
credConfig.Config,
credConfig.Prefix,
)
if err != nil {
return nil, fmt.Errorf("failed to initialize credential manager: %v", err)
}
// Use credential manager for operations
}
```
## Benefits
1. **Easy Configuration** - Generate template with `weed scaffold -config=credential`
2. **Pluggable Storage** - Switch between filer_etc, PostgreSQL without code changes
3. **Backward Compatibility** - Filer-based storage works with existing deployments
4. **Scalability** - Database stores support multiple concurrent servers
5. **Performance** - Database access can be faster than file-based storage
6. **Testing** - Memory store simplifies unit testing
7. **Environment Override** - All settings can be overridden with environment variables
## Error Handling
When a credential store is configured, it must initialize successfully or the server will fail to start:
```go
if credConfig != nil {
credentialManager, err = credential.NewCredentialManager(...)
if err != nil {
return nil, fmt.Errorf("failed to initialize credential manager: %v", err)
}
}
```
This ensures explicit configuration - if you configure a credential store, it must work properly.

View File

@@ -0,0 +1,97 @@
package credential
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// CredentialConfig represents the credential configuration from credential.toml
type CredentialConfig struct {
Store string
Config util.Configuration
Prefix string
}
// LoadCredentialConfiguration loads credential configuration from credential.toml
// Returns the store type, configuration, and prefix for credential management
func LoadCredentialConfiguration() (*CredentialConfig, error) {
// Try to load credential.toml configuration
loaded := util.LoadConfiguration("credential", false)
if !loaded {
glog.V(1).Info("No credential.toml found, credential store disabled")
return nil, nil
}
viper := util.GetViper()
// Find which credential store is enabled
var enabledStore string
var storePrefix string
// Get available store types from registered stores
storeTypes := GetAvailableStores()
for _, storeType := range storeTypes {
key := fmt.Sprintf("credential.%s.enabled", string(storeType))
if viper.GetBool(key) {
if enabledStore != "" {
return nil, fmt.Errorf("multiple credential stores enabled: %s and %s. Only one store can be enabled", enabledStore, string(storeType))
}
enabledStore = string(storeType)
storePrefix = fmt.Sprintf("credential.%s.", string(storeType))
}
}
if enabledStore == "" {
glog.V(1).Info("No credential store enabled in credential.toml")
return nil, nil
}
glog.V(0).Infof("Loaded credential configuration: store=%s", enabledStore)
return &CredentialConfig{
Store: enabledStore,
Config: viper,
Prefix: storePrefix,
}, nil
}
// NewCredentialManagerWithDefaults creates a credential manager with fallback to defaults
// If explicitStore is provided, it will be used regardless of credential.toml
// If explicitStore is empty, it tries credential.toml first, then defaults to "filer_etc"
func NewCredentialManagerWithDefaults(explicitStore CredentialStoreTypeName) (*CredentialManager, error) {
var storeName CredentialStoreTypeName
var config util.Configuration
var prefix string
// If explicit store is provided, use it
if explicitStore != "" {
storeName = explicitStore
config = nil
prefix = ""
glog.V(0).Infof("Using explicit credential store: %s", storeName)
} else {
// Try to load from credential.toml first
if credConfig, err := LoadCredentialConfiguration(); err == nil && credConfig != nil {
storeName = CredentialStoreTypeName(credConfig.Store)
config = credConfig.Config
prefix = credConfig.Prefix
glog.V(0).Infof("Loaded credential configuration from credential.toml: store=%s", storeName)
} else {
// Default to filer_etc store
storeName = StoreTypeFilerEtc
config = nil
prefix = ""
glog.V(1).Info("No credential.toml found, defaulting to filer_etc store")
}
}
// Create the credential manager
credentialManager, err := NewCredentialManager(storeName, config, prefix)
if err != nil {
return nil, fmt.Errorf("failed to initialize credential manager with store '%s': %v", storeName, err)
}
return credentialManager, nil
}

View File

@@ -0,0 +1,484 @@
package credential
import (
"context"
"fmt"
"os"
"strings"
"sync"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/grpc"
"google.golang.org/protobuf/encoding/protojson"
)
// FilerAddressSetter is an interface for credential stores that need a dynamic filer address
type FilerAddressSetter interface {
SetFilerAddressFunc(getFiler func() pb.ServerAddress, grpcDialOption grpc.DialOption)
}
// CredentialManager manages user credentials using a configurable store
type CredentialManager struct {
Store CredentialStore
// staticMu protects staticIdentities and staticNames, which are written
// by SetStaticIdentities (startup + config reload) and read concurrently
// by LoadConfiguration, SaveConfiguration, GetStaticUsernames, and IsStaticIdentity.
staticMu sync.RWMutex
// staticIdentities holds identities loaded from a static config file (-s3.config).
// These are included in LoadConfiguration so that listing operations
// return all configured identities, not just dynamic ones from the store.
staticIdentities []*iam_pb.Identity
staticNames map[string]bool
}
// NewCredentialManager creates a new credential manager with the specified store
func NewCredentialManager(storeName CredentialStoreTypeName, configuration util.Configuration, prefix string) (*CredentialManager, error) {
var store CredentialStore
// Find the requested store implementation
for _, s := range Stores {
if s.GetName() == storeName {
store = s
break
}
}
if store == nil {
return nil, fmt.Errorf("credential store '%s' not found. Available stores: %s",
storeName, getAvailableStores())
}
// Initialize the store
if err := store.Initialize(configuration, prefix); err != nil {
return nil, fmt.Errorf("failed to initialize credential store '%s': %v", storeName, err)
}
return &CredentialManager{
Store: store,
}, nil
}
func (cm *CredentialManager) SetMasterClient(masterClient *wdclient.MasterClient, grpcDialOption grpc.DialOption) {
cm.Store = NewPropagatingCredentialStore(cm.Store, masterClient, grpcDialOption)
}
// SetFilerAddressFunc sets the function to get the current filer address
func (cm *CredentialManager) SetFilerAddressFunc(getFiler func() pb.ServerAddress, grpcDialOption grpc.DialOption) {
if s, ok := cm.Store.(FilerAddressSetter); ok {
s.SetFilerAddressFunc(getFiler, grpcDialOption)
}
}
// GetStore returns the underlying credential store
func (cm *CredentialManager) GetStore() CredentialStore {
return cm.Store
}
// GetStoreName returns the name of the underlying credential store
func (cm *CredentialManager) GetStoreName() string {
if cm.Store != nil {
return string(cm.Store.GetName())
}
return ""
}
// SetStaticIdentities registers identities loaded from a static config file.
// These identities are included in LoadConfiguration and ListUsers results
// but are never persisted to the dynamic store.
func (cm *CredentialManager) SetStaticIdentities(identities []*iam_pb.Identity) {
filtered := make([]*iam_pb.Identity, 0, len(identities))
names := make(map[string]bool, len(identities))
for _, ident := range identities {
if ident != nil {
filtered = append(filtered, ident)
names[ident.Name] = true
}
}
cm.staticMu.Lock()
cm.staticIdentities = filtered
cm.staticNames = names
cm.staticMu.Unlock()
}
// IsStaticIdentity returns true if the named identity was loaded from static config.
func (cm *CredentialManager) IsStaticIdentity(name string) bool {
cm.staticMu.RLock()
defer cm.staticMu.RUnlock()
return cm.staticNames[name]
}
// GetStaticIdentity returns the protobuf identity for a static user, or nil.
func (cm *CredentialManager) GetStaticIdentity(name string) *iam_pb.Identity {
cm.staticMu.RLock()
defer cm.staticMu.RUnlock()
for _, ident := range cm.staticIdentities {
if ident.Name == name {
return ident
}
}
return nil
}
// GetStaticUsernames returns the names of all static identities.
func (cm *CredentialManager) GetStaticUsernames() []string {
cm.staticMu.RLock()
defer cm.staticMu.RUnlock()
names := make([]string, 0, len(cm.staticIdentities))
for _, ident := range cm.staticIdentities {
names = append(names, ident.Name)
}
return names
}
// LoadConfiguration loads the S3 API configuration from the store and merges
// in any static identities so that listing operations show all users.
func (cm *CredentialManager) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
config, err := cm.Store.LoadConfiguration(ctx)
if err != nil {
return config, err
}
// Merge static identities that are not already in the dynamic config
cm.staticMu.RLock()
staticIdents := cm.staticIdentities
cm.staticMu.RUnlock()
if len(staticIdents) > 0 {
dynamicNames := make(map[string]bool, len(config.Identities))
for _, ident := range config.Identities {
dynamicNames[ident.Name] = true
}
for _, si := range staticIdents {
if !dynamicNames[si.Name] {
config.Identities = append(config.Identities, si)
}
}
}
return config, nil
}
// SaveConfiguration saves the S3 API configuration.
// Static identities are filtered out before saving to the store.
// The caller's config is not mutated.
func (cm *CredentialManager) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
cm.staticMu.RLock()
staticNames := cm.staticNames
cm.staticMu.RUnlock()
if len(staticNames) > 0 {
var dynamicOnly []*iam_pb.Identity
for _, ident := range config.Identities {
if !staticNames[ident.Name] {
dynamicOnly = append(dynamicOnly, ident)
}
}
configCopy := *config
configCopy.Identities = dynamicOnly
return cm.Store.SaveConfiguration(ctx, &configCopy)
}
return cm.Store.SaveConfiguration(ctx, config)
}
// CreateUser creates a new user
func (cm *CredentialManager) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
return cm.Store.CreateUser(ctx, identity)
}
// GetUser retrieves a user by username
func (cm *CredentialManager) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
return cm.Store.GetUser(ctx, username)
}
// UpdateUser updates an existing user
func (cm *CredentialManager) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
return cm.Store.UpdateUser(ctx, username, identity)
}
// DeleteUser removes a user
func (cm *CredentialManager) DeleteUser(ctx context.Context, username string) error {
return cm.Store.DeleteUser(ctx, username)
}
// ListUsers returns usernames from the dynamic store via cm.Store.ListUsers.
// On store error the error is returned directly without merging static entries.
// Static identities (cm.staticIdentities) are NOT included here because
// internal callers (e.g. DeletePolicy) look up each user in the store and
// would fail on non-existent static entries. External callers that need the
// full list should merge GetStaticUsernames separately.
func (cm *CredentialManager) ListUsers(ctx context.Context) ([]string, error) {
return cm.Store.ListUsers(ctx)
}
// GetUserByAccessKey retrieves a user by access key
func (cm *CredentialManager) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
return cm.Store.GetUserByAccessKey(ctx, accessKey)
}
// CreateAccessKey creates a new access key for a user
func (cm *CredentialManager) CreateAccessKey(ctx context.Context, username string, credential *iam_pb.Credential) error {
return cm.Store.CreateAccessKey(ctx, username, credential)
}
// DeleteAccessKey removes an access key for a user
func (cm *CredentialManager) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
return cm.Store.DeleteAccessKey(ctx, username, accessKey)
}
// GetPolicies returns all policies
func (cm *CredentialManager) GetPolicies(ctx context.Context) (map[string]policy_engine.PolicyDocument, error) {
return cm.Store.GetPolicies(ctx)
}
// PutPolicy creates or updates a policy
func (cm *CredentialManager) PutPolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
return cm.Store.PutPolicy(ctx, name, document)
}
// DeletePolicy removes a policy
func (cm *CredentialManager) DeletePolicy(ctx context.Context, name string) error {
return cm.Store.DeletePolicy(ctx, name)
}
// GetPolicy retrieves a policy by name
func (cm *CredentialManager) GetPolicy(ctx context.Context, name string) (*policy_engine.PolicyDocument, error) {
return cm.Store.GetPolicy(ctx, name)
}
// ListPolicyNames returns the names of all policies
func (cm *CredentialManager) ListPolicyNames(ctx context.Context) ([]string, error) {
return cm.Store.ListPolicyNames(ctx)
}
// CreatePolicy creates a new policy (if supported by the store)
func (cm *CredentialManager) CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
// Check if the store implements PolicyManager interface with CreatePolicy
if policyStore, ok := cm.Store.(PolicyManager); ok {
return policyStore.CreatePolicy(ctx, name, document)
}
// Fallback to PutPolicy for stores that only implement CredentialStore
return cm.Store.PutPolicy(ctx, name, document)
}
// UpdatePolicy updates an existing policy (if supported by the store)
func (cm *CredentialManager) UpdatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
// Check if the store implements PolicyManager interface with UpdatePolicy
if policyStore, ok := cm.Store.(PolicyManager); ok {
return policyStore.UpdatePolicy(ctx, name, document)
}
// Fallback to PutPolicy for stores that only implement CredentialStore
return cm.Store.PutPolicy(ctx, name, document)
}
// PutUserInlinePolicy stores a per-user inline policy document.
// Returns nil without error if the underlying store does not support inline policies.
func (cm *CredentialManager) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error {
if store, ok := cm.Store.(InlinePolicyStore); ok {
return store.PutUserInlinePolicy(ctx, userName, policyName, document)
}
return nil
}
// GetUserInlinePolicy retrieves a per-user inline policy document.
// Returns nil without error if the underlying store does not support inline policies.
func (cm *CredentialManager) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) {
if store, ok := cm.Store.(InlinePolicyStore); ok {
return store.GetUserInlinePolicy(ctx, userName, policyName)
}
return nil, nil
}
// DeleteUserInlinePolicy removes a per-user inline policy document.
func (cm *CredentialManager) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error {
if store, ok := cm.Store.(InlinePolicyStore); ok {
return store.DeleteUserInlinePolicy(ctx, userName, policyName)
}
return nil
}
// ListUserInlinePolicies returns the names of all inline policies for a user.
func (cm *CredentialManager) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) {
if store, ok := cm.Store.(InlinePolicyStore); ok {
return store.ListUserInlinePolicies(ctx, userName)
}
return nil, nil
}
// LoadAllInlinePolicies returns every user inline policy in a single map
// keyed by username then policy name. Returns nil, nil if the underlying
// store does not implement bulk loading.
func (cm *CredentialManager) LoadAllInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
if loader, ok := cm.Store.(InlinePoliciesLoader); ok {
return loader.LoadInlinePolicies(ctx)
}
return nil, nil
}
// PutGroupInlinePolicy stores a per-group inline policy document.
// Returns nil without error if the underlying store does not support
// group inline policies.
func (cm *CredentialManager) PutGroupInlinePolicy(ctx context.Context, groupName, policyName string, document policy_engine.PolicyDocument) error {
if store, ok := cm.Store.(GroupInlinePolicyStore); ok {
return store.PutGroupInlinePolicy(ctx, groupName, policyName, document)
}
return nil
}
// GetGroupInlinePolicy retrieves a per-group inline policy document.
func (cm *CredentialManager) GetGroupInlinePolicy(ctx context.Context, groupName, policyName string) (*policy_engine.PolicyDocument, error) {
if store, ok := cm.Store.(GroupInlinePolicyStore); ok {
return store.GetGroupInlinePolicy(ctx, groupName, policyName)
}
return nil, nil
}
// DeleteGroupInlinePolicy removes a per-group inline policy document.
func (cm *CredentialManager) DeleteGroupInlinePolicy(ctx context.Context, groupName, policyName string) error {
if store, ok := cm.Store.(GroupInlinePolicyStore); ok {
return store.DeleteGroupInlinePolicy(ctx, groupName, policyName)
}
return nil
}
// ListGroupInlinePolicies returns the names of all inline policies for a group.
func (cm *CredentialManager) ListGroupInlinePolicies(ctx context.Context, groupName string) ([]string, error) {
if store, ok := cm.Store.(GroupInlinePolicyStore); ok {
return store.ListGroupInlinePolicies(ctx, groupName)
}
return nil, nil
}
// LoadAllGroupInlinePolicies returns every group inline policy in a single
// map keyed by group name then policy name. Returns nil, nil if the
// underlying store does not implement bulk loading.
func (cm *CredentialManager) LoadAllGroupInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
if loader, ok := cm.Store.(GroupInlinePoliciesLoader); ok {
return loader.LoadGroupInlinePolicies(ctx)
}
return nil, nil
}
// RenameUser atomically renames a user along with all FK-backed
// dependents (credentials, inline policies, ...) when the underlying
// store implements UserRenamer. Returns false if the store does not
// support an atomic rename, in which case the caller is expected to
// fall back to its own best-effort path. A returned non-nil error
// always means the rename was attempted and failed.
func (cm *CredentialManager) RenameUser(ctx context.Context, oldName, newName string) (bool, error) {
store, ok := cm.Store.(UserRenamer)
if !ok {
return false, nil
}
return true, store.RenameUser(ctx, oldName, newName)
}
// LoadS3ConfigFile reads a static S3 identity config file and registers
// the identities so they appear in LoadConfiguration and listing results.
func (cm *CredentialManager) LoadS3ConfigFile(path string) error {
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
config := &iam_pb.S3ApiConfiguration{}
opts := protojson.UnmarshalOptions{DiscardUnknown: true, AllowPartial: true}
if err := opts.Unmarshal(content, config); err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
for _, ident := range config.Identities {
ident.IsStatic = true
}
cm.SetStaticIdentities(config.Identities)
glog.V(1).Infof("Loaded %d static identities from %s", len(config.Identities), path)
return nil
}
// Shutdown performs cleanup
func (cm *CredentialManager) Shutdown() {
if cm.Store != nil {
cm.Store.Shutdown()
}
}
// getAvailableStores returns a comma-separated list of available store names
func getAvailableStores() string {
var storeNames []string
for _, store := range Stores {
storeNames = append(storeNames, string(store.GetName()))
}
return strings.Join(storeNames, ", ")
}
// GetAvailableStores returns a list of available credential store names
func GetAvailableStores() []CredentialStoreTypeName {
var storeNames []CredentialStoreTypeName
for _, store := range Stores {
storeNames = append(storeNames, store.GetName())
}
if storeNames == nil {
return []CredentialStoreTypeName{}
}
return storeNames
}
// CreateServiceAccount creates a new service account
func (cm *CredentialManager) CreateServiceAccount(ctx context.Context, sa *iam_pb.ServiceAccount) error {
return cm.Store.CreateServiceAccount(ctx, sa)
}
// UpdateServiceAccount updates an existing service account
func (cm *CredentialManager) UpdateServiceAccount(ctx context.Context, id string, sa *iam_pb.ServiceAccount) error {
return cm.Store.UpdateServiceAccount(ctx, id, sa)
}
// DeleteServiceAccount removes a service account
func (cm *CredentialManager) DeleteServiceAccount(ctx context.Context, id string) error {
return cm.Store.DeleteServiceAccount(ctx, id)
}
// GetServiceAccount retrieves a service account by ID
func (cm *CredentialManager) GetServiceAccount(ctx context.Context, id string) (*iam_pb.ServiceAccount, error) {
return cm.Store.GetServiceAccount(ctx, id)
}
// ListServiceAccounts returns all service accounts
func (cm *CredentialManager) ListServiceAccounts(ctx context.Context) ([]*iam_pb.ServiceAccount, error) {
return cm.Store.ListServiceAccounts(ctx)
}
// AttachUserPolicy attaches a managed policy to a user
func (cm *CredentialManager) AttachUserPolicy(ctx context.Context, username string, policyName string) error {
return cm.Store.AttachUserPolicy(ctx, username, policyName)
}
// DetachUserPolicy detaches a managed policy from a user
func (cm *CredentialManager) DetachUserPolicy(ctx context.Context, username string, policyName string) error {
return cm.Store.DetachUserPolicy(ctx, username, policyName)
}
// ListAttachedUserPolicies returns the list of policy names attached to a user
func (cm *CredentialManager) ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error) {
return cm.Store.ListAttachedUserPolicies(ctx, username)
}
// Group Management
func (cm *CredentialManager) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
return cm.Store.CreateGroup(ctx, group)
}
func (cm *CredentialManager) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
return cm.Store.GetGroup(ctx, groupName)
}
func (cm *CredentialManager) DeleteGroup(ctx context.Context, groupName string) error {
return cm.Store.DeleteGroup(ctx, groupName)
}
func (cm *CredentialManager) ListGroups(ctx context.Context) ([]string, error) {
return cm.Store.ListGroups(ctx)
}
func (cm *CredentialManager) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
return cm.Store.UpdateGroup(ctx, group)
}

View File

@@ -0,0 +1,187 @@
package credential
import (
"context"
"errors"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/util"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExists = errors.New("user already exists")
ErrAccessKeyNotFound = errors.New("access key not found")
ErrServiceAccountNotFound = errors.New("service account not found")
ErrPolicyNotFound = errors.New("policy not found")
ErrPolicyAlreadyAttached = errors.New("policy already attached")
ErrPolicyNotAttached = errors.New("policy not attached to user")
ErrGroupNotFound = errors.New("group not found")
ErrGroupAlreadyExists = errors.New("group already exists")
ErrGroupNotEmpty = errors.New("group is not empty")
ErrUserNotInGroup = errors.New("user is not a member of the group")
)
// CredentialStoreTypeName represents the type name of a credential store
type CredentialStoreTypeName string
// Credential store name constants
const (
StoreTypeMemory CredentialStoreTypeName = "memory"
StoreTypeFilerEtc CredentialStoreTypeName = "filer_etc"
StoreTypePostgres CredentialStoreTypeName = "postgres"
StoreTypeGrpc CredentialStoreTypeName = "grpc"
)
// CredentialStore defines the interface for user credential storage and retrieval
type CredentialStore interface {
// GetName returns the name of the credential store implementation
GetName() CredentialStoreTypeName
// Initialize initializes the credential store with configuration
Initialize(configuration util.Configuration, prefix string) error
// LoadConfiguration loads the entire S3 API configuration
LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error)
// SaveConfiguration saves the entire S3 API configuration
SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error
// CreateUser creates a new user with the given identity
CreateUser(ctx context.Context, identity *iam_pb.Identity) error
// GetUser retrieves a user by username
GetUser(ctx context.Context, username string) (*iam_pb.Identity, error)
// UpdateUser updates an existing user
UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error
// DeleteUser removes a user by username
DeleteUser(ctx context.Context, username string) error
// ListUsers returns all usernames
ListUsers(ctx context.Context) ([]string, error)
// GetUserByAccessKey retrieves a user by access key
GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error)
// CreateAccessKey creates a new access key for a user
CreateAccessKey(ctx context.Context, username string, credential *iam_pb.Credential) error
// DeleteAccessKey removes an access key for a user
DeleteAccessKey(ctx context.Context, username string, accessKey string) error
// Policy Management
GetPolicies(ctx context.Context) (map[string]policy_engine.PolicyDocument, error)
// ListPolicyNames returns the names of all policies
ListPolicyNames(ctx context.Context) ([]string, error)
// PutPolicy creates or replaces a policy document.
PutPolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error
DeletePolicy(ctx context.Context, name string) error
GetPolicy(ctx context.Context, name string) (*policy_engine.PolicyDocument, error)
// Service Account Management
CreateServiceAccount(ctx context.Context, sa *iam_pb.ServiceAccount) error
UpdateServiceAccount(ctx context.Context, id string, sa *iam_pb.ServiceAccount) error
DeleteServiceAccount(ctx context.Context, id string) error
GetServiceAccount(ctx context.Context, id string) (*iam_pb.ServiceAccount, error)
ListServiceAccounts(ctx context.Context) ([]*iam_pb.ServiceAccount, error)
GetServiceAccountByAccessKey(ctx context.Context, accessKey string) (*iam_pb.ServiceAccount, error)
// User Policy Attachment Management
// AttachUserPolicy attaches a managed policy to a user by policy name
AttachUserPolicy(ctx context.Context, username string, policyName string) error
// DetachUserPolicy detaches a managed policy from a user
DetachUserPolicy(ctx context.Context, username string, policyName string) error
// ListAttachedUserPolicies returns the list of policy names attached to a user
ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error)
// Group Management
CreateGroup(ctx context.Context, group *iam_pb.Group) error
GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error)
DeleteGroup(ctx context.Context, groupName string) error
ListGroups(ctx context.Context) ([]string, error)
UpdateGroup(ctx context.Context, group *iam_pb.Group) error
// Shutdown performs cleanup when the store is being shut down
Shutdown()
}
// AccessKeyInfo represents access key information with metadata
type AccessKeyInfo struct {
AccessKey string `json:"accessKey"`
SecretKey string `json:"secretKey"`
Username string `json:"username"`
CreatedAt time.Time `json:"createdAt"`
}
// UserCredentials represents a user's credentials and metadata
type UserCredentials struct {
Username string `json:"username"`
Email string `json:"email"`
Account *iam_pb.Account `json:"account,omitempty"`
Credentials []*iam_pb.Credential `json:"credentials"`
Actions []string `json:"actions"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// PolicyManager interface for managing IAM policies
type PolicyManager interface {
GetPolicies(ctx context.Context) (map[string]policy_engine.PolicyDocument, error)
CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error
UpdatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error
DeletePolicy(ctx context.Context, name string) error
GetPolicy(ctx context.Context, name string) (*policy_engine.PolicyDocument, error)
}
// InlinePolicyStore is an optional interface for credential stores that support
// per-user inline policy storage. Stores that implement this interface preserve
// the exact policy document submitted via PutUserPolicy, enabling lossless
// round-trips through GetUserPolicy.
type InlinePolicyStore interface {
PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error
GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error)
DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error
ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error)
}
// GroupInlinePolicyStore is an optional interface for credential stores that
// support per-group inline policy storage. Mirrors InlinePolicyStore so the
// IAM API can persist group-attached inline policies without writing a
// shared filer file.
type GroupInlinePolicyStore interface {
PutGroupInlinePolicy(ctx context.Context, groupName, policyName string, document policy_engine.PolicyDocument) error
GetGroupInlinePolicy(ctx context.Context, groupName, policyName string) (*policy_engine.PolicyDocument, error)
DeleteGroupInlinePolicy(ctx context.Context, groupName, policyName string) error
ListGroupInlinePolicies(ctx context.Context, groupName string) ([]string, error)
}
// InlinePoliciesLoader is an optional interface for credential stores that can
// bulk-load every user inline policy in a single round trip.
type InlinePoliciesLoader interface {
LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error)
}
// GroupInlinePoliciesLoader is an optional interface for credential stores
// that can bulk-load every group inline policy in a single round trip.
type GroupInlinePoliciesLoader interface {
LoadGroupInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error)
}
// UserRenamer is an optional interface for credential stores that can
// atomically rename a user along with all rows that reference the old
// username (credentials, inline policies, etc). Backends with referential
// integrity (e.g. PostgreSQL with ON DELETE CASCADE foreign keys on
// user_inline_policies) MUST implement this so the IAM rename path can
// move the dependents within a single transaction; copying them via
// per-row Put / Get / Delete calls would violate the FK at statement
// time because the new user row does not yet exist.
type UserRenamer interface {
RenameUser(ctx context.Context, oldName, newName string) error
}
// Stores holds all available credential store implementations
var Stores []CredentialStore

View File

@@ -0,0 +1,353 @@
package credential
import (
"context"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func TestCredentialStoreInterface(t *testing.T) {
// Note: This test may fail if run without importing store packages
// For full integration testing, see the test/ package
if len(Stores) == 0 {
t.Skip("No credential stores registered - this is expected when testing the base package without store imports")
}
// Check that expected stores are available
storeNames := GetAvailableStores()
expectedStores := []string{string(StoreTypeFilerEtc), string(StoreTypeMemory)}
// Add PostgreSQL if it's available (build tags dependent)
for _, storeName := range storeNames {
found := false
for _, expected := range append(expectedStores, string(StoreTypePostgres)) {
if string(storeName) == expected {
found = true
break
}
}
if !found {
t.Errorf("Unexpected store found: %s", storeName)
}
}
// Test that filer_etc store is always available
filerEtcStoreFound := false
memoryStoreFound := false
for _, storeName := range storeNames {
if string(storeName) == string(StoreTypeFilerEtc) {
filerEtcStoreFound = true
}
if string(storeName) == string(StoreTypeMemory) {
memoryStoreFound = true
}
}
if !filerEtcStoreFound {
t.Error("FilerEtc store should always be available")
}
if !memoryStoreFound {
t.Error("Memory store should always be available")
}
}
func TestCredentialManagerCreation(t *testing.T) {
config := util.GetViper()
// Test creating credential manager with invalid store
_, err := NewCredentialManager(CredentialStoreTypeName("nonexistent"), config, "test.")
if err == nil {
t.Error("Expected error for nonexistent store")
}
// Skip store-specific tests if no stores are registered
if len(Stores) == 0 {
t.Skip("No credential stores registered - skipping store-specific tests")
}
// Test creating credential manager with available stores
availableStores := GetAvailableStores()
if len(availableStores) == 0 {
t.Skip("No stores available for testing")
}
// Test with the first available store
storeName := availableStores[0]
cm, err := NewCredentialManager(storeName, config, "test.")
if err != nil {
t.Fatalf("Failed to create credential manager with store %s: %v", storeName, err)
}
if cm == nil {
t.Error("Credential manager should not be nil")
}
defer cm.Shutdown()
// Test that the store is of the correct type
if cm.GetStore().GetName() != storeName {
t.Errorf("Expected %s store, got %s", storeName, cm.GetStore().GetName())
}
}
func TestCredentialInterface(t *testing.T) {
// Skip if no stores are registered
if len(Stores) == 0 {
t.Skip("No credential stores registered - for full testing see test/ package")
}
// Test the interface with the first available store
availableStores := GetAvailableStores()
if len(availableStores) == 0 {
t.Skip("No stores available for testing")
}
testCredentialInterfaceWithStore(t, availableStores[0])
}
func testCredentialInterfaceWithStore(t *testing.T, storeName CredentialStoreTypeName) {
// Create a test identity
testIdentity := &iam_pb.Identity{
Name: "testuser",
Actions: []string{"Read", "Write"},
Account: &iam_pb.Account{
Id: "123456789012",
DisplayName: "Test User",
EmailAddress: "test@example.com",
},
Credentials: []*iam_pb.Credential{
{
AccessKey: "AKIAIOSFODNN7EXAMPLE",
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
},
}
// Test the interface methods exist (compile-time check)
config := util.GetViper()
cm, err := NewCredentialManager(storeName, config, "test.")
if err != nil {
t.Fatalf("Failed to create credential manager: %v", err)
}
defer cm.Shutdown()
ctx := context.Background()
// Test LoadConfiguration
_, err = cm.LoadConfiguration(ctx)
if err != nil {
t.Fatalf("LoadConfiguration failed: %v", err)
}
// Test CreateUser
err = cm.CreateUser(ctx, testIdentity)
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
// Test GetUser
user, err := cm.GetUser(ctx, "testuser")
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if user.Name != "testuser" {
t.Errorf("Expected user name 'testuser', got %s", user.Name)
}
// Test ListUsers
users, err := cm.ListUsers(ctx)
if err != nil {
t.Fatalf("ListUsers failed: %v", err)
}
if len(users) != 1 || users[0] != "testuser" {
t.Errorf("Expected ['testuser'], got %v", users)
}
// Test GetUserByAccessKey
userByKey, err := cm.GetUserByAccessKey(ctx, "AKIAIOSFODNN7EXAMPLE")
if err != nil {
t.Fatalf("GetUserByAccessKey failed: %v", err)
}
if userByKey.Name != "testuser" {
t.Errorf("Expected user name 'testuser', got %s", userByKey.Name)
}
}
func TestCredentialManagerIntegration(t *testing.T) {
// Skip if no stores are registered
if len(Stores) == 0 {
t.Skip("No credential stores registered - for full testing see test/ package")
}
// Test with the first available store
availableStores := GetAvailableStores()
if len(availableStores) == 0 {
t.Skip("No stores available for testing")
}
storeName := availableStores[0]
config := util.GetViper()
cm, err := NewCredentialManager(storeName, config, "test.")
if err != nil {
t.Fatalf("Failed to create credential manager: %v", err)
}
defer cm.Shutdown()
ctx := context.Background()
// Test complete workflow
user1 := &iam_pb.Identity{
Name: "user1",
Actions: []string{"Read"},
Account: &iam_pb.Account{
Id: "111111111111",
DisplayName: "User One",
EmailAddress: "user1@example.com",
},
Credentials: []*iam_pb.Credential{
{
AccessKey: "AKIAUSER1",
SecretKey: "secret1",
},
},
}
user2 := &iam_pb.Identity{
Name: "user2",
Actions: []string{"Write"},
Account: &iam_pb.Account{
Id: "222222222222",
DisplayName: "User Two",
EmailAddress: "user2@example.com",
},
Credentials: []*iam_pb.Credential{
{
AccessKey: "AKIAUSER2",
SecretKey: "secret2",
},
},
}
// Create users
err = cm.CreateUser(ctx, user1)
if err != nil {
t.Fatalf("Failed to create user1: %v", err)
}
err = cm.CreateUser(ctx, user2)
if err != nil {
t.Fatalf("Failed to create user2: %v", err)
}
// List users
users, err := cm.ListUsers(ctx)
if err != nil {
t.Fatalf("Failed to list users: %v", err)
}
if len(users) != 2 {
t.Errorf("Expected 2 users, got %d", len(users))
}
// Test access key lookup
foundUser, err := cm.GetUserByAccessKey(ctx, "AKIAUSER1")
if err != nil {
t.Fatalf("Failed to get user by access key: %v", err)
}
if foundUser.Name != "user1" {
t.Errorf("Expected user1, got %s", foundUser.Name)
}
// Delete user
err = cm.DeleteUser(ctx, "user1")
if err != nil {
t.Fatalf("Failed to delete user: %v", err)
}
// Verify user is deleted
_, err = cm.GetUser(ctx, "user1")
if err != ErrUserNotFound {
t.Errorf("Expected ErrUserNotFound, got %v", err)
}
// Clean up
err = cm.DeleteUser(ctx, "user2")
if err != nil {
t.Fatalf("Failed to delete user2: %v", err)
}
}
// TestErrorTypes tests that the custom error types are defined correctly
func TestErrorTypes(t *testing.T) {
// Test that error types are defined
if ErrUserNotFound == nil {
t.Error("ErrUserNotFound should be defined")
}
if ErrUserAlreadyExists == nil {
t.Error("ErrUserAlreadyExists should be defined")
}
if ErrAccessKeyNotFound == nil {
t.Error("ErrAccessKeyNotFound should be defined")
}
// Test error messages
if ErrUserNotFound.Error() != "user not found" {
t.Errorf("Expected 'user not found', got '%s'", ErrUserNotFound.Error())
}
if ErrUserAlreadyExists.Error() != "user already exists" {
t.Errorf("Expected 'user already exists', got '%s'", ErrUserAlreadyExists.Error())
}
if ErrAccessKeyNotFound.Error() != "access key not found" {
t.Errorf("Expected 'access key not found', got '%s'", ErrAccessKeyNotFound.Error())
}
}
// TestGetAvailableStores tests the store discovery function
func TestGetAvailableStores(t *testing.T) {
stores := GetAvailableStores()
if len(stores) == 0 {
t.Skip("No stores available for testing")
}
// Convert to strings for comparison
storeNames := make([]string, len(stores))
for i, store := range stores {
storeNames[i] = string(store)
}
t.Logf("Available stores: %v (count: %d)", storeNames, len(storeNames))
// We expect at least memory and filer_etc stores to be available
expectedStores := []string{string(StoreTypeFilerEtc), string(StoreTypeMemory)}
// Add PostgreSQL if it's available (build tags dependent)
for _, storeName := range storeNames {
found := false
for _, expected := range append(expectedStores, string(StoreTypePostgres)) {
if storeName == expected {
found = true
break
}
}
if !found {
t.Errorf("Unexpected store found: %s", storeName)
}
}
// Test that filer_etc store is always available
filerEtcStoreFound := false
memoryStoreFound := false
for _, storeName := range storeNames {
if storeName == string(StoreTypeFilerEtc) {
filerEtcStoreFound = true
}
if storeName == string(StoreTypeMemory) {
memoryStoreFound = true
}
}
if !filerEtcStoreFound {
t.Error("FilerEtc store should always be available")
}
if !memoryStoreFound {
t.Error("Memory store should always be available")
}
}

View File

@@ -0,0 +1,189 @@
package filer_etc
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
const IamGroupsDirectory = "groups"
func (store *FilerEtcStore) loadGroupsFromMultiFile(ctx context.Context, s3cfg *iam_pb.S3ApiConfiguration) error {
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamGroupsDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDirectory {
continue
}
if !strings.HasSuffix(entry.Name, ".json") {
continue
}
var content []byte
if len(entry.Content) > 0 {
content = entry.Content
} else {
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
if err != nil {
return fmt.Errorf("failed to read group file %s: %w", entry.Name, err)
}
content = c
}
if len(content) == 0 {
return fmt.Errorf("group file %s is empty", entry.Name)
}
g := &iam_pb.Group{}
if err := json.Unmarshal(content, g); err != nil {
return fmt.Errorf("failed to unmarshal group %s: %w", entry.Name, err)
}
if g.Name == "" {
return fmt.Errorf("group file %s has empty name", entry.Name)
}
// Merge: overwrite existing group with same name or append
found := false
for i, existing := range s3cfg.Groups {
if existing.Name == g.Name {
s3cfg.Groups[i] = g
found = true
break
}
}
if !found {
s3cfg.Groups = append(s3cfg.Groups, g)
}
}
return nil
})
}
func (store *FilerEtcStore) saveGroup(ctx context.Context, group *iam_pb.Group) error {
if group == nil {
return fmt.Errorf("group is nil")
}
group.Name = strings.TrimSpace(group.Name)
if group.Name == "" {
return fmt.Errorf("group name is required")
}
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
data, err := json.MarshalIndent(group, "", " ")
if err != nil {
return err
}
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory+"/"+IamGroupsDirectory, group.Name+".json", data)
})
}
func (store *FilerEtcStore) deleteGroupFile(ctx context.Context, groupName string) error {
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
Directory: filer.IamConfigDirectory + "/" + IamGroupsDirectory,
Name: groupName + ".json",
})
if err != nil {
if strings.Contains(err.Error(), filer_pb.ErrNotFound.Error()) {
return credential.ErrGroupNotFound
}
return err
}
if resp != nil && resp.Error != "" {
if strings.Contains(resp.Error, filer_pb.ErrNotFound.Error()) {
return credential.ErrGroupNotFound
}
return fmt.Errorf("delete group %s: %s", groupName, resp.Error)
}
return nil
})
}
func (store *FilerEtcStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
if group != nil {
group.Name = strings.TrimSpace(group.Name)
}
if group == nil || group.Name == "" {
return fmt.Errorf("group name is required")
}
existing, err := store.GetGroup(ctx, group.Name)
if err != nil {
if !errors.Is(err, credential.ErrGroupNotFound) {
return err
}
} else if existing != nil {
return credential.ErrGroupAlreadyExists
}
return store.saveGroup(ctx, group)
}
func (store *FilerEtcStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
var group *iam_pb.Group
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
data, err := filer.ReadInsideFiler(ctx, client, filer.IamConfigDirectory+"/"+IamGroupsDirectory, groupName+".json")
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return credential.ErrGroupNotFound
}
return err
}
if len(data) == 0 {
return credential.ErrGroupNotFound
}
group = &iam_pb.Group{}
return json.Unmarshal(data, group)
})
return group, err
}
func (store *FilerEtcStore) DeleteGroup(ctx context.Context, groupName string) error {
if _, err := store.GetGroup(ctx, groupName); err != nil {
return err
}
return store.deleteGroupFile(ctx, groupName)
}
func (store *FilerEtcStore) ListGroups(ctx context.Context) ([]string, error) {
var names []string
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
entries, err := listEntries(ctx, client, filer.IamConfigDirectory+"/"+IamGroupsDirectory)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
return err
}
for _, entry := range entries {
if !entry.IsDirectory && strings.HasSuffix(entry.Name, ".json") {
names = append(names, strings.TrimSuffix(entry.Name, ".json"))
}
}
return nil
})
return names, err
}
func (store *FilerEtcStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
if group != nil {
group.Name = strings.TrimSpace(group.Name)
}
if group == nil || group.Name == "" {
return fmt.Errorf("group name is required")
}
if _, err := store.GetGroup(ctx, group.Name); err != nil {
return err
}
return store.saveGroup(ctx, group)
}

View File

@@ -0,0 +1,580 @@
package filer_etc
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
const (
IamIdentitiesDirectory = "identities"
IamServiceAccountsDirectory = "service_accounts"
IamLegacyIdentityFile = "identity.json"
IamLegacyIdentityOldFile = "identity.json.old"
)
func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
s3cfg := &iam_pb.S3ApiConfiguration{}
// 1. Load from legacy single file (low priority)
content, foundLegacy, err := store.readInsideFiler(ctx, filer.IamConfigDirectory, IamLegacyIdentityFile)
if err != nil {
return s3cfg, err
}
if foundLegacy && len(content) > 0 {
if err := filer.ParseS3ConfigurationFromBytes(content, s3cfg); err != nil {
glog.Errorf("Failed to parse legacy IAM configuration: %v", err)
return s3cfg, err
}
}
// 2. Load from multi-file structure (high priority, overrides legacy details)
if _, err := store.loadFromMultiFile(ctx, s3cfg); err != nil {
return s3cfg, err
}
// 3. Load service accounts
if err := store.loadServiceAccountsFromMultiFile(ctx, s3cfg); err != nil {
return s3cfg, fmt.Errorf("failed to load service accounts: %w", err)
}
// 3b. Load groups
if err := store.loadGroupsFromMultiFile(ctx, s3cfg); err != nil {
return s3cfg, fmt.Errorf("failed to load groups: %w", err)
}
// 4. Perform migration if we loaded legacy config
// This ensures that all identities (including legacy ones) are written to individual files
// and the legacy file is renamed.
if foundLegacy {
if err := store.migrateToMultiFile(ctx, s3cfg); err != nil {
glog.Errorf("Failed to migrate IAM configuration to multi-file layout: %v", err)
return s3cfg, nil
}
}
return s3cfg, nil
}
func (store *FilerEtcStore) loadFromMultiFile(ctx context.Context, s3cfg *iam_pb.S3ApiConfiguration) (bool, error) {
var hasIdentities bool
// Helper to find existing identity index
findIdentity := func(name string) int {
for i, identity := range s3cfg.Identities {
if identity.Name == name {
return i
}
}
return -1
}
// 1. List identities
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamIdentitiesDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
if err == filer_pb.ErrNotFound {
// If directory doesn't exist, it's not multi-file yet
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDirectory {
continue
}
if !strings.HasSuffix(entry.Name, ".json") {
continue
}
hasIdentities = true
var content []byte
if len(entry.Content) > 0 {
content = entry.Content
} else {
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
if err != nil {
return fmt.Errorf("failed to read identity file %s: %w", entry.Name, err)
}
content = c
}
if len(content) == 0 {
return fmt.Errorf("identity file %s is empty", entry.Name)
}
identity := &iam_pb.Identity{}
if err := json.Unmarshal(content, identity); err != nil {
return fmt.Errorf("failed to unmarshal identity %s: %w", entry.Name, err)
}
if identity.Name == "" {
return fmt.Errorf("identity file %s has empty name", entry.Name)
}
// Merge logic: Overwrite existing or Append
idx := findIdentity(identity.Name)
if idx != -1 {
s3cfg.Identities[idx] = identity
} else {
s3cfg.Identities = append(s3cfg.Identities, identity)
}
}
return nil
})
if err != nil {
return false, err
}
return hasIdentities, nil
}
func (store *FilerEtcStore) migrateToMultiFile(ctx context.Context, s3cfg *iam_pb.S3ApiConfiguration) error {
glog.Infof("Migrating IAM configuration to multi-file layout...")
// 1. Save all identities
for _, identity := range s3cfg.Identities {
if err := store.saveIdentity(ctx, identity); err != nil {
return err
}
}
// 2. Save all service accounts
for _, sa := range s3cfg.ServiceAccounts {
if err := store.saveServiceAccount(ctx, sa); err != nil {
return err
}
}
// 3. Save all groups
for _, g := range s3cfg.Groups {
if err := store.saveGroup(ctx, g); err != nil {
return err
}
}
// 4. Rename legacy file
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
_, err := client.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{
OldDirectory: filer.IamConfigDirectory,
OldName: IamLegacyIdentityFile,
NewDirectory: filer.IamConfigDirectory,
NewName: IamLegacyIdentityOldFile,
})
return err
})
}
func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
// 1. Save all identities
for _, identity := range config.Identities {
if err := store.saveIdentity(ctx, identity); err != nil {
return err
}
}
// 2. Save all service accounts
for _, sa := range config.ServiceAccounts {
if err := store.saveServiceAccount(ctx, sa); err != nil {
return err
}
}
// 2b. Save all groups
for _, g := range config.Groups {
if err := store.saveGroup(ctx, g); err != nil {
return err
}
}
// 3. Cleanup removed identities (Full Sync)
if err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamIdentitiesDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
validNames := make(map[string]bool)
for _, id := range config.Identities {
validNames[id.Name+".json"] = true
}
for _, entry := range entries {
if !entry.IsDirectory && !validNames[entry.Name] {
// Delete obsolete identity file
if err := filer_pb.DoRemove(ctx, client, dir, entry.Name, false, false, false, false, nil); err != nil {
glog.Warningf("Failed to delete obsolete identity file %s: %v", entry.Name, err)
}
}
}
return nil
}); err != nil {
return err
}
// 4. Cleanup removed service accounts (Full Sync)
if err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamServiceAccountsDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
validNames := make(map[string]bool)
for _, sa := range config.ServiceAccounts {
validNames[sa.Id+".json"] = true
}
for _, entry := range entries {
if !entry.IsDirectory && !validNames[entry.Name] {
if err := filer_pb.DoRemove(ctx, client, dir, entry.Name, false, false, false, false, nil); err != nil {
glog.Warningf("Failed to delete obsolete service account file %s: %v", entry.Name, err)
}
}
}
return nil
}); err != nil {
return err
}
// 5. Cleanup removed groups (Full Sync)
if err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamGroupsDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
validNames := make(map[string]bool)
for _, g := range config.Groups {
validNames[g.Name+".json"] = true
}
for _, entry := range entries {
if !entry.IsDirectory && !validNames[entry.Name] {
if err := filer_pb.DoRemove(ctx, client, dir, entry.Name, false, false, false, false, nil); err != nil {
glog.Warningf("Failed to delete obsolete group file %s: %v", entry.Name, err)
}
}
}
return nil
}); err != nil {
return err
}
return nil
}
func (store *FilerEtcStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
// Check if user exists (read specific file)
existing, err := store.GetUser(ctx, identity.Name)
if err == nil && existing != nil {
return credential.ErrUserAlreadyExists
}
return store.saveIdentity(ctx, identity)
}
func (store *FilerEtcStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
var identity *iam_pb.Identity
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
data, err := filer.ReadInsideFiler(ctx, client, filer.IamConfigDirectory+"/"+IamIdentitiesDirectory, username+".json")
if err != nil {
if err == filer_pb.ErrNotFound {
return credential.ErrUserNotFound
}
return err
}
if len(data) == 0 {
return credential.ErrUserNotFound
}
identity = &iam_pb.Identity{}
return json.Unmarshal(data, identity)
})
return identity, err
}
func (store *FilerEtcStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
// Ensure user exists
if _, err := store.GetUser(ctx, username); err != nil {
return err
}
// If username changes, we need to delete old and create new. But usually UpdateUser keeps username unless renames are allowed.
// identity.Name vs username.
if username != identity.Name {
// Rename case
if err := store.DeleteUser(ctx, username); err != nil {
return err
}
}
return store.saveIdentity(ctx, identity)
}
func (store *FilerEtcStore) DeleteUser(ctx context.Context, username string) error {
// Verify existence first to return ErrUserNotFound if applicable
if _, err := store.GetUser(ctx, username); err != nil {
return err
}
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
Directory: filer.IamConfigDirectory + "/" + IamIdentitiesDirectory,
Name: username + ".json",
})
if err != nil {
if strings.Contains(err.Error(), filer_pb.ErrNotFound.Error()) {
return credential.ErrUserNotFound
}
return err
}
if resp != nil && resp.Error != "" {
if strings.Contains(resp.Error, filer_pb.ErrNotFound.Error()) {
return credential.ErrUserNotFound
}
return fmt.Errorf("delete user %s: %s", username, resp.Error)
}
return nil
})
}
func (store *FilerEtcStore) ListUsers(ctx context.Context) ([]string, error) {
var usernames []string
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
entries, err := listEntries(ctx, client, filer.IamConfigDirectory+"/"+IamIdentitiesDirectory)
if err != nil {
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
for _, entry := range entries {
if !entry.IsDirectory && len(entry.Name) > 5 && entry.Name[len(entry.Name)-5:] == ".json" {
usernames = append(usernames, entry.Name[:len(entry.Name)-5])
}
}
return nil
})
return usernames, err
}
func (store *FilerEtcStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
// Optimized: Iterate over identity files directly instead of loading full config.
// This avoids triggering migration side effects.
var foundIdentity *iam_pb.Identity
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamIdentitiesDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
// If not found, check legacy file? No, optimization requested to avoid side effects.
// If migration hasn't run, this will return empty/not found.
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDirectory || !strings.HasSuffix(entry.Name, ".json") {
continue
}
// Read file content
var content []byte
if len(entry.Content) > 0 {
content = entry.Content
} else {
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
if err != nil {
continue
}
content = c
}
if len(content) > 0 {
identity := &iam_pb.Identity{}
if err := json.Unmarshal(content, identity); err != nil {
continue
}
for _, cred := range identity.Credentials {
if cred.AccessKey == accessKey {
foundIdentity = identity
return nil // Found match, stop iteration
}
}
}
}
return nil
})
if err != nil {
return nil, err
}
if foundIdentity != nil {
return foundIdentity, nil
}
return nil, credential.ErrAccessKeyNotFound
}
func (store *FilerEtcStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
identity, err := store.GetUser(ctx, username)
if err != nil {
return err
}
for _, existing := range identity.Credentials {
if existing.AccessKey == cred.AccessKey {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
}
identity.Credentials = append(identity.Credentials, cred)
return store.saveIdentity(ctx, identity)
}
func (store *FilerEtcStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
identity, err := store.GetUser(ctx, username)
if err != nil {
return err
}
found := false
for i, cred := range identity.Credentials {
if cred.AccessKey == accessKey {
identity.Credentials = append(identity.Credentials[:i], identity.Credentials[i+1:]...)
found = true
break
}
}
if !found {
return credential.ErrAccessKeyNotFound
}
return store.saveIdentity(ctx, identity)
}
// Helpers
func (store *FilerEtcStore) saveIdentity(ctx context.Context, identity *iam_pb.Identity) error {
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
data, err := json.MarshalIndent(identity, "", " ")
if err != nil {
return err
}
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory+"/"+IamIdentitiesDirectory, identity.Name+".json", data)
})
}
func (store *FilerEtcStore) readInsideFiler(ctx context.Context, dir string, name string) ([]byte, bool, error) {
var content []byte
found := false
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
c, err := filer.ReadInsideFiler(ctx, client, dir, name)
if err != nil {
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
content = c
found = true
return nil
})
return content, found, err
}
func listEntries(ctx context.Context, client filer_pb.SeaweedFilerClient, dir string) ([]*filer_pb.Entry, error) {
var entries []*filer_pb.Entry
err := filer_pb.SeaweedList(ctx, client, dir, "", func(entry *filer_pb.Entry, isLast bool) error {
entries = append(entries, entry)
return nil
}, "", false, 100000)
if err != nil {
return nil, err
}
return entries, nil
}
// AttachUserPolicy attaches a managed policy to a user by policy name
func (store *FilerEtcStore) AttachUserPolicy(ctx context.Context, username string, policyName string) error {
// Get user
identity, err := store.GetUser(ctx, username)
if err != nil {
return err
}
// Verify policy exists
policy, err := store.GetPolicy(ctx, policyName)
if err != nil {
return err
}
if policy == nil {
return credential.ErrPolicyNotFound
}
// Check if already attached
for _, p := range identity.PolicyNames {
if p == policyName {
return credential.ErrPolicyAlreadyAttached
}
}
identity.PolicyNames = append(identity.PolicyNames, policyName)
return store.saveIdentity(ctx, identity)
}
// DetachUserPolicy detaches a managed policy from a user
func (store *FilerEtcStore) DetachUserPolicy(ctx context.Context, username string, policyName string) error {
identity, err := store.GetUser(ctx, username)
if err != nil {
return err
}
found := false
var newPolicies []string
for _, p := range identity.PolicyNames {
if p == policyName {
found = true
} else {
newPolicies = append(newPolicies, p)
}
}
if !found {
return credential.ErrPolicyNotAttached
}
identity.PolicyNames = newPolicies
return store.saveIdentity(ctx, identity)
}
// ListAttachedUserPolicies returns the list of policy names attached to a user
func (store *FilerEtcStore) ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error) {
identity, err := store.GetUser(ctx, username)
if err != nil {
return nil, err
}
return identity.PolicyNames, nil
}

View File

@@ -0,0 +1,144 @@
package filer_etc
import (
"context"
"testing"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func putEntry(t *testing.T, server *policyTestFilerServer, dir, name string, content []byte) {
t.Helper()
_, err := server.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
Directory: dir,
Entry: &filer_pb.Entry{Name: name, Content: content},
})
require.NoError(t, err)
}
// A mid-rewrite identity file (empty content, as seen while a secrets tool
// truncates and rewrites the file) must fail the snapshot instead of being
// silently dropped. Silently dropping it would make a full reload install an
// incomplete identity set and deny unrelated clients mid-reload.
func TestLoadConfigurationFailsOnEmptyIdentityFile(t *testing.T) {
ctx := context.Background()
store, server := newPolicyTestStoreWithServer(t)
identDir := filer.IamConfigDirectory + "/" + IamIdentitiesDirectory
putEntry(t, server, identDir, "alice.json", []byte(`{"name":"alice","credentials":[{"accessKey":"AK","secretKey":"SK"}]}`))
putEntry(t, server, identDir, "bob.json", []byte{}) // mid-rewrite: empty
_, err := store.LoadConfiguration(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "bob.json")
}
// A malformed identity file (partial JSON from a mid-rewrite) must also fail
// the snapshot rather than being skipped.
func TestLoadConfigurationFailsOnMalformedIdentityFile(t *testing.T) {
ctx := context.Background()
store, server := newPolicyTestStoreWithServer(t)
identDir := filer.IamConfigDirectory + "/" + IamIdentitiesDirectory
putEntry(t, server, identDir, "alice.json", []byte(`{"name":"alice"}`))
putEntry(t, server, identDir, "bob.json", []byte(`{"name":"bob"`)) // truncated
_, err := store.LoadConfiguration(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "bob.json")
}
// A mid-rewrite policy file must fail the snapshot rather than be dropped.
func TestLoadManagedPoliciesFailsOnEmptyPolicyFile(t *testing.T) {
ctx := context.Background()
store, server := newPolicyTestStoreWithServer(t)
polDir := filer.IamConfigDirectory + "/" + IamPoliciesDirectory
putEntry(t, server, polDir, "good.json", []byte(`{"Version":"2012-10-17","Statement":[]}`))
putEntry(t, server, polDir, "bad.json", []byte{}) // mid-rewrite: empty
_, err := store.LoadManagedPolicies(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "bad.json")
}
// A non-JSON auxiliary file (README, .DS_Store, a migration backup) in an IAM
// directory must not fail the snapshot: only *.json files are IAM objects.
func TestLoadConfigurationIgnoresNonJsonAuxiliaryFiles(t *testing.T) {
ctx := context.Background()
store, server := newPolicyTestStoreWithServer(t)
identDir := filer.IamConfigDirectory + "/" + IamIdentitiesDirectory
putEntry(t, server, identDir, "alice.json", []byte(`{"name":"alice","credentials":[{"accessKey":"AK","secretKey":"SK"}]}`))
putEntry(t, server, identDir, "README.txt", []byte(`not an identity`))
putEntry(t, server, identDir, "alice.json.old", []byte(`{"name":"alice"}`))
polDir := filer.IamConfigDirectory + "/" + IamPoliciesDirectory
putEntry(t, server, polDir, "p.json", []byte(`{"Version":"2012-10-17","Statement":[]}`))
putEntry(t, server, polDir, "notes.md", []byte(`# policies`))
cfg, err := store.LoadConfiguration(ctx)
require.NoError(t, err)
require.Len(t, cfg.Identities, 1)
assert.Equal(t, "alice", cfg.Identities[0].Name)
}
// A valid JSON file with an empty name (e.g. `{}`) would unmarshal cleanly but
// install a garbage empty-key record that can displace a real one. Reject it.
func TestLoadConfigurationFailsOnEmptyIdentityName(t *testing.T) {
ctx := context.Background()
store, server := newPolicyTestStoreWithServer(t)
identDir := filer.IamConfigDirectory + "/" + IamIdentitiesDirectory
putEntry(t, server, identDir, "alice.json", []byte(`{"name":"alice"}`))
putEntry(t, server, identDir, "empty.json", []byte(`{}`))
_, err := store.LoadConfiguration(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "empty.json")
}
func TestLoadConfigurationFailsOnEmptyGroupName(t *testing.T) {
ctx := context.Background()
store, server := newPolicyTestStoreWithServer(t)
groupDir := filer.IamConfigDirectory + "/" + IamGroupsDirectory
putEntry(t, server, groupDir, "empty.json", []byte(`{}`))
_, err := store.LoadConfiguration(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "empty.json")
}
func TestLoadConfigurationFailsOnEmptyServiceAccountId(t *testing.T) {
ctx := context.Background()
store, server := newPolicyTestStoreWithServer(t)
saDir := filer.IamConfigDirectory + "/" + IamServiceAccountsDirectory
putEntry(t, server, saDir, "empty.json", []byte(`{}`))
_, err := store.LoadConfiguration(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "empty.json")
}
// A valid full snapshot loads without error (regression guard).
func TestLoadConfigurationSucceedsOnValidFiles(t *testing.T) {
ctx := context.Background()
store, server := newPolicyTestStoreWithServer(t)
identDir := filer.IamConfigDirectory + "/" + IamIdentitiesDirectory
putEntry(t, server, identDir, "alice.json", []byte(`{"name":"alice","credentials":[{"accessKey":"AK","secretKey":"SK"}]}`))
putEntry(t, server, identDir, "bob.json", []byte(`{"name":"bob","credentials":[{"accessKey":"BK","secretKey":"SK"}]}`))
cfg, err := store.LoadConfiguration(ctx)
require.NoError(t, err)
names := make([]string, 0, len(cfg.Identities))
for _, id := range cfg.Identities {
names = append(names, id.Name)
}
assert.ElementsMatch(t, []string{"alice", "bob"}, names)
}

View File

@@ -0,0 +1,517 @@
package filer_etc
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
)
const (
IamPoliciesDirectory = "policies"
IamLegacyPoliciesOldFile = "policies.json.old"
)
type PoliciesCollection struct {
Policies map[string]policy_engine.PolicyDocument `json:"policies"`
InlinePolicies map[string]map[string]policy_engine.PolicyDocument `json:"inlinePolicies"`
GroupInlinePolicies map[string]map[string]policy_engine.PolicyDocument `json:"groupInlinePolicies,omitempty"`
}
func validatePolicyName(name string) error {
return credential.ValidatePolicyName(name)
}
func newPoliciesCollection() *PoliciesCollection {
return &PoliciesCollection{
Policies: make(map[string]policy_engine.PolicyDocument),
InlinePolicies: make(map[string]map[string]policy_engine.PolicyDocument),
GroupInlinePolicies: make(map[string]map[string]policy_engine.PolicyDocument),
}
}
func (store *FilerEtcStore) loadLegacyPoliciesCollection(ctx context.Context) (*PoliciesCollection, bool, error) {
policiesCollection := newPoliciesCollection()
content, foundLegacy, err := store.readInsideFiler(ctx, filer.IamConfigDirectory, filer.IamPoliciesFile)
if err != nil {
return nil, false, err
}
if !foundLegacy || len(content) == 0 {
return policiesCollection, foundLegacy, nil
}
if err := json.Unmarshal(content, policiesCollection); err != nil {
return nil, false, err
}
if policiesCollection.Policies == nil {
policiesCollection.Policies = make(map[string]policy_engine.PolicyDocument)
}
if policiesCollection.InlinePolicies == nil {
policiesCollection.InlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument)
}
if policiesCollection.GroupInlinePolicies == nil {
policiesCollection.GroupInlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument)
}
return policiesCollection, true, nil
}
func (store *FilerEtcStore) saveLegacyPoliciesCollection(ctx context.Context, policiesCollection *PoliciesCollection) error {
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
content, err := json.MarshalIndent(policiesCollection, "", " ")
if err != nil {
return err
}
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory, filer.IamPoliciesFile, content)
})
}
func policyDocumentToPbPolicy(name string, policy policy_engine.PolicyDocument) (*iam_pb.Policy, error) {
content, err := json.Marshal(policy)
if err != nil {
return nil, err
}
return &iam_pb.Policy{Name: name, Content: string(content)}, nil
}
// LoadManagedPolicies loads managed policies for the S3 runtime without
// triggering legacy-to-multifile migration. This lets the runtime hydrate
// policies while preserving any legacy inline policy data stored alongside
// managed policies.
func (store *FilerEtcStore) LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error) {
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
policies := make(map[string]policy_engine.PolicyDocument, len(policiesCollection.Policies))
for name, policy := range policiesCollection.Policies {
policies[name] = policy
}
if err := store.loadPoliciesFromMultiFile(ctx, policies); err != nil {
return nil, err
}
managedPolicies := make([]*iam_pb.Policy, 0, len(policies))
for name, policy := range policies {
pbPolicy, err := policyDocumentToPbPolicy(name, policy)
if err != nil {
return nil, err
}
managedPolicies = append(managedPolicies, pbPolicy)
}
return managedPolicies, nil
}
// LoadInlinePolicies loads legacy inline policies keyed by user name. Inline
// policies are still stored in the legacy shared policies file.
func (store *FilerEtcStore) LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
inlinePolicies := make(map[string]map[string]policy_engine.PolicyDocument, len(policiesCollection.InlinePolicies))
for userName, userPolicies := range policiesCollection.InlinePolicies {
inlinePolicies[userName] = make(map[string]policy_engine.PolicyDocument, len(userPolicies))
for policyName, policy := range userPolicies {
inlinePolicies[userName][policyName] = policy
}
}
return inlinePolicies, nil
}
// GetPolicies retrieves all IAM policies from the filer
func (store *FilerEtcStore) GetPolicies(ctx context.Context) (map[string]policy_engine.PolicyDocument, error) {
policies := make(map[string]policy_engine.PolicyDocument)
// Check if filer client is configured (with mutex protection)
store.mu.RLock()
configured := store.filerAddressFunc != nil
store.mu.RUnlock()
if !configured {
glog.V(1).Infof("Filer client not configured for policy retrieval, returning empty policies")
return policies, nil
}
glog.V(2).Infof("Loading IAM policies from %s/%s (using current active filer)",
filer.IamConfigDirectory, filer.IamPoliciesFile)
// 1. Load from legacy single file (low priority)
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
for name, policy := range policiesCollection.Policies {
policies[name] = policy
}
// 2. Load from multi-file structure (high priority, overrides legacy)
if err := store.loadPoliciesFromMultiFile(ctx, policies); err != nil {
return nil, err
}
return policies, nil
}
func (store *FilerEtcStore) loadPoliciesFromMultiFile(ctx context.Context, policies map[string]policy_engine.PolicyDocument) error {
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamPoliciesDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDirectory {
continue
}
if !strings.HasSuffix(entry.Name, ".json") {
continue
}
var content []byte
if len(entry.Content) > 0 {
content = entry.Content
} else {
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
if err != nil {
return fmt.Errorf("failed to read policy file %s: %w", entry.Name, err)
}
content = c
}
if len(content) == 0 {
return fmt.Errorf("policy file %s is empty", entry.Name)
}
var policy policy_engine.PolicyDocument
if err := json.Unmarshal(content, &policy); err != nil {
return fmt.Errorf("failed to unmarshal policy %s: %w", entry.Name, err)
}
policyName := strings.TrimSuffix(entry.Name, ".json")
policies[policyName] = policy
}
return nil
})
}
func (store *FilerEtcStore) savePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
if err := validatePolicyName(name); err != nil {
return err
}
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
data, err := json.Marshal(document)
if err != nil {
return err
}
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory+"/"+IamPoliciesDirectory, name+".json", data)
})
}
// CreatePolicy creates a new IAM policy in the filer
func (store *FilerEtcStore) CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
return store.savePolicy(ctx, name, document)
}
// UpdatePolicy updates an existing IAM policy in the filer
func (store *FilerEtcStore) UpdatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
return store.savePolicy(ctx, name, document)
}
// PutPolicy creates or updates an IAM policy in the filer
func (store *FilerEtcStore) PutPolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
return store.UpdatePolicy(ctx, name, document)
}
// DeletePolicy deletes an IAM policy from the filer
func (store *FilerEtcStore) DeletePolicy(ctx context.Context, name string) error {
if err := validatePolicyName(name); err != nil {
return err
}
store.policyMu.Lock()
defer store.policyMu.Unlock()
policiesCollection, foundLegacy, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return err
}
deleteLegacyPolicy := false
if foundLegacy {
if _, exists := policiesCollection.Policies[name]; exists {
delete(policiesCollection.Policies, name)
deleteLegacyPolicy = true
}
}
if err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
return filer_pb.DoRemove(ctx, client, filer.IamConfigDirectory+"/"+IamPoliciesDirectory, name+".json", false, false, false, false, nil)
}); err != nil {
return err
}
if deleteLegacyPolicy {
return store.saveLegacyPoliciesCollection(ctx, policiesCollection)
}
return nil
}
// GetPolicy retrieves a specific IAM policy by name from the filer
func (store *FilerEtcStore) GetPolicy(ctx context.Context, name string) (*policy_engine.PolicyDocument, error) {
if err := validatePolicyName(name); err != nil {
return nil, err
}
var policy *policy_engine.PolicyDocument
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
data, err := filer.ReadInsideFiler(ctx, client, filer.IamConfigDirectory+"/"+IamPoliciesDirectory, name+".json")
if err != nil {
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
if len(data) == 0 {
return nil
}
policy = &policy_engine.PolicyDocument{}
return json.Unmarshal(data, policy)
})
if policy != nil {
return policy, err
}
// fallback to full list if single file read fails (e.g. before migration completes or if partially migrated)
// Although migration should happen on first GetPolicies call.
policies, err := store.GetPolicies(ctx)
if err != nil {
return nil, err
}
if p, exists := policies[name]; exists {
return &p, nil
}
return nil, nil // Policy not found
}
// PutUserInlinePolicy stores a per-user inline policy document.
func (store *FilerEtcStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error {
store.policyMu.Lock()
defer store.policyMu.Unlock()
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return err
}
if policiesCollection.InlinePolicies[userName] == nil {
policiesCollection.InlinePolicies[userName] = make(map[string]policy_engine.PolicyDocument)
}
policiesCollection.InlinePolicies[userName][policyName] = document
return store.saveLegacyPoliciesCollection(ctx, policiesCollection)
}
// GetUserInlinePolicy retrieves a per-user inline policy document.
func (store *FilerEtcStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) {
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
if userPolicies := policiesCollection.InlinePolicies[userName]; userPolicies != nil {
if doc, exists := userPolicies[policyName]; exists {
return &doc, nil
}
}
return nil, nil
}
// DeleteUserInlinePolicy removes a per-user inline policy document.
func (store *FilerEtcStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error {
store.policyMu.Lock()
defer store.policyMu.Unlock()
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return err
}
if userPolicies := policiesCollection.InlinePolicies[userName]; userPolicies != nil {
delete(userPolicies, policyName)
if len(userPolicies) == 0 {
delete(policiesCollection.InlinePolicies, userName)
}
}
return store.saveLegacyPoliciesCollection(ctx, policiesCollection)
}
// ListUserInlinePolicies returns the names of all inline policies for a user.
func (store *FilerEtcStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) {
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
userPolicies := policiesCollection.InlinePolicies[userName]
names := make([]string, 0, len(userPolicies))
for name := range userPolicies {
names = append(names, name)
}
return names, nil
}
// PutGroupInlinePolicy stores a per-group inline policy document.
func (store *FilerEtcStore) PutGroupInlinePolicy(ctx context.Context, groupName, policyName string, document policy_engine.PolicyDocument) error {
store.policyMu.Lock()
defer store.policyMu.Unlock()
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return err
}
if policiesCollection.GroupInlinePolicies[groupName] == nil {
policiesCollection.GroupInlinePolicies[groupName] = make(map[string]policy_engine.PolicyDocument)
}
policiesCollection.GroupInlinePolicies[groupName][policyName] = document
return store.saveLegacyPoliciesCollection(ctx, policiesCollection)
}
// GetGroupInlinePolicy retrieves a per-group inline policy document.
func (store *FilerEtcStore) GetGroupInlinePolicy(ctx context.Context, groupName, policyName string) (*policy_engine.PolicyDocument, error) {
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
if groupPolicies := policiesCollection.GroupInlinePolicies[groupName]; groupPolicies != nil {
if doc, exists := groupPolicies[policyName]; exists {
return &doc, nil
}
}
return nil, nil
}
// DeleteGroupInlinePolicy removes a per-group inline policy document.
func (store *FilerEtcStore) DeleteGroupInlinePolicy(ctx context.Context, groupName, policyName string) error {
store.policyMu.Lock()
defer store.policyMu.Unlock()
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return err
}
if groupPolicies := policiesCollection.GroupInlinePolicies[groupName]; groupPolicies != nil {
delete(groupPolicies, policyName)
if len(groupPolicies) == 0 {
delete(policiesCollection.GroupInlinePolicies, groupName)
}
}
return store.saveLegacyPoliciesCollection(ctx, policiesCollection)
}
// ListGroupInlinePolicies returns the names of all inline policies for a group.
func (store *FilerEtcStore) ListGroupInlinePolicies(ctx context.Context, groupName string) ([]string, error) {
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
groupPolicies := policiesCollection.GroupInlinePolicies[groupName]
names := make([]string, 0, len(groupPolicies))
for name := range groupPolicies {
names = append(names, name)
}
return names, nil
}
// LoadGroupInlinePolicies returns all group inline policies stored in the
// legacy file keyed by group name then policy name.
func (store *FilerEtcStore) LoadGroupInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
result := make(map[string]map[string]policy_engine.PolicyDocument, len(policiesCollection.GroupInlinePolicies))
for groupName, groupPolicies := range policiesCollection.GroupInlinePolicies {
copied := make(map[string]policy_engine.PolicyDocument, len(groupPolicies))
for policyName, doc := range groupPolicies {
copied[policyName] = doc
}
result[groupName] = copied
}
return result, nil
}
// ListPolicyNames returns all managed policy names stored in the filer.
func (store *FilerEtcStore) ListPolicyNames(ctx context.Context) ([]string, error) {
names := make([]string, 0)
seenNames := make(map[string]struct{})
store.mu.RLock()
configured := store.filerAddressFunc != nil
store.mu.RUnlock()
if !configured {
return names, nil
}
policiesCollection, _, err := store.loadLegacyPoliciesCollection(ctx)
if err != nil {
return nil, err
}
for name := range policiesCollection.Policies {
if _, found := seenNames[name]; found {
continue
}
names = append(names, name)
seenNames[name] = struct{}{}
}
err = store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamPoliciesDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDirectory {
continue
}
if !strings.HasSuffix(entry.Name, ".json") {
continue
}
name := entry.Name[:len(entry.Name)-5]
if _, found := seenNames[name]; found {
continue
}
names = append(names, name)
seenNames[name] = struct{}{}
}
return nil
})
if err != nil {
return nil, err
}
return names, nil
}

View File

@@ -0,0 +1,386 @@
package filer_etc
import (
"context"
"net"
"sort"
"strconv"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
type policyTestFilerServer struct {
filer_pb.UnimplementedSeaweedFilerServer
mu sync.RWMutex
entries map[string]*filer_pb.Entry
contentlessListEntry map[string]struct{}
beforeLookup func(context.Context, string, string) error
afterListEntry func(string, string)
beforeDelete func(string, string) error
beforeUpdate func(string, string) error
}
func newPolicyTestFilerServer() *policyTestFilerServer {
return &policyTestFilerServer{
entries: make(map[string]*filer_pb.Entry),
contentlessListEntry: make(map[string]struct{}),
}
}
func (s *policyTestFilerServer) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
s.mu.RLock()
beforeLookup := s.beforeLookup
s.mu.RUnlock()
if beforeLookup != nil {
if err := beforeLookup(ctx, req.Directory, req.Name); err != nil {
return nil, err
}
}
s.mu.RLock()
defer s.mu.RUnlock()
entry, found := s.entries[filerEntryKey(req.Directory, req.Name)]
if !found {
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
}
return &filer_pb.LookupDirectoryEntryResponse{Entry: cloneEntry(entry)}, nil
}
func (s *policyTestFilerServer) ListEntries(req *filer_pb.ListEntriesRequest, stream grpc.ServerStreamingServer[filer_pb.ListEntriesResponse]) error {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0)
for key := range s.entries {
dir, name := splitFilerEntryKey(key)
if dir != req.Directory {
continue
}
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
entry := cloneEntry(s.entries[filerEntryKey(req.Directory, name)])
if _, found := s.contentlessListEntry[filerEntryKey(req.Directory, name)]; found {
entry.Content = nil
}
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: entry}); err != nil {
return err
}
if s.afterListEntry != nil {
s.afterListEntry(req.Directory, name)
}
}
return nil
}
func (s *policyTestFilerServer) CreateEntry(_ context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.entries[filerEntryKey(req.Directory, req.Entry.Name)] = cloneEntry(req.Entry)
return &filer_pb.CreateEntryResponse{}, nil
}
func (s *policyTestFilerServer) UpdateEntry(_ context.Context, req *filer_pb.UpdateEntryRequest) (*filer_pb.UpdateEntryResponse, error) {
s.mu.RLock()
beforeUpdate := s.beforeUpdate
s.mu.RUnlock()
if beforeUpdate != nil {
if err := beforeUpdate(req.Directory, req.Entry.Name); err != nil {
return nil, err
}
}
s.mu.Lock()
defer s.mu.Unlock()
s.entries[filerEntryKey(req.Directory, req.Entry.Name)] = cloneEntry(req.Entry)
return &filer_pb.UpdateEntryResponse{}, nil
}
func (s *policyTestFilerServer) DeleteEntry(_ context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
s.mu.RLock()
beforeDelete := s.beforeDelete
s.mu.RUnlock()
if beforeDelete != nil {
if err := beforeDelete(req.Directory, req.Name); err != nil {
return nil, err
}
}
s.mu.Lock()
defer s.mu.Unlock()
key := filerEntryKey(req.Directory, req.Name)
if _, found := s.entries[key]; !found {
return nil, status.Error(codes.NotFound, filer_pb.ErrNotFound.Error())
}
delete(s.entries, key)
return &filer_pb.DeleteEntryResponse{}, nil
}
func newPolicyTestStore(t *testing.T) *FilerEtcStore {
store, _ := newPolicyTestStoreWithServer(t)
return store
}
func newPolicyTestStoreWithServer(t *testing.T) (*FilerEtcStore, *policyTestFilerServer) {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
server := newPolicyTestFilerServer()
grpcServer := pb.NewGrpcServer()
filer_pb.RegisterSeaweedFilerServer(grpcServer, server)
go func() {
_ = grpcServer.Serve(lis)
}()
t.Cleanup(func() {
grpcServer.Stop()
_ = lis.Close()
})
store := &FilerEtcStore{}
host, portString, err := net.SplitHostPort(lis.Addr().String())
require.NoError(t, err)
grpcPort, err := strconv.Atoi(portString)
require.NoError(t, err)
store.SetFilerAddressFunc(func() pb.ServerAddress {
return pb.NewServerAddress(host, 1, grpcPort)
}, grpc.WithTransportCredentials(insecure.NewCredentials()))
return store, server
}
func TestFilerEtcStoreListPolicyNamesIncludesLegacyPolicies(t *testing.T) {
ctx := context.Background()
store := newPolicyTestStore(t)
legacyPolicies := newPoliciesCollection()
legacyPolicies.Policies["legacy-only"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::legacy-only/*")
legacyPolicies.Policies["shared"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::shared/*")
require.NoError(t, store.saveLegacyPoliciesCollection(ctx, legacyPolicies))
require.NoError(t, store.savePolicy(ctx, "multi-file-only", testPolicyDocument("s3:PutObject", "arn:aws:s3:::multi-file-only/*")))
require.NoError(t, store.savePolicy(ctx, "shared", testPolicyDocument("s3:DeleteObject", "arn:aws:s3:::shared/*")))
names, err := store.ListPolicyNames(ctx)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"legacy-only", "multi-file-only", "shared"}, names)
}
// A non-JSON auxiliary file in the policies directory must not be listed as a
// policy name (it cannot be retrieved by GetPolicy either).
func TestFilerEtcStoreListPolicyNamesSkipsNonJsonAuxiliary(t *testing.T) {
ctx := context.Background()
store, server := newPolicyTestStoreWithServer(t)
require.NoError(t, store.savePolicy(ctx, "real", testPolicyDocument("s3:GetObject", "arn:aws:s3:::real/*")))
polDir := filer.IamConfigDirectory + "/" + IamPoliciesDirectory
putEntry(t, server, polDir, "notes.md", []byte(`# not a policy`))
names, err := store.ListPolicyNames(ctx)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"real"}, names)
}
func TestFilerEtcStoreDeletePolicyRemovesLegacyManagedCopy(t *testing.T) {
ctx := context.Background()
store := newPolicyTestStore(t)
inlinePolicy := testPolicyDocument("s3:PutObject", "arn:aws:s3:::inline-user/*")
legacyPolicies := newPoliciesCollection()
legacyPolicies.Policies["legacy-only"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::legacy-only/*")
legacyPolicies.InlinePolicies["inline-user"] = map[string]policy_engine.PolicyDocument{
"PutOnly": inlinePolicy,
}
require.NoError(t, store.saveLegacyPoliciesCollection(ctx, legacyPolicies))
managedPolicies, err := store.LoadManagedPolicies(ctx)
require.NoError(t, err)
assert.Equal(t, []string{"legacy-only"}, managedPolicyNames(managedPolicies))
require.NoError(t, store.DeletePolicy(ctx, "legacy-only"))
managedPolicies, err = store.LoadManagedPolicies(ctx)
require.NoError(t, err)
assert.Empty(t, managedPolicies)
inlinePolicies, err := store.LoadInlinePolicies(ctx)
require.NoError(t, err)
assertInlinePolicyPreserved(t, inlinePolicies, "inline-user", "PutOnly")
loadedLegacyPolicies, foundLegacy, err := store.loadLegacyPoliciesCollection(ctx)
require.NoError(t, err)
require.True(t, foundLegacy)
assert.Empty(t, loadedLegacyPolicies.Policies)
assertInlinePolicyPreserved(t, loadedLegacyPolicies.InlinePolicies, "inline-user", "PutOnly")
}
func TestFilerEtcStoreDeletePolicySerializesLegacyUpdates(t *testing.T) {
ctx := context.Background()
store, server := newPolicyTestStoreWithServer(t)
legacyPolicies := newPoliciesCollection()
legacyPolicies.Policies["first"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::first/*")
legacyPolicies.Policies["second"] = testPolicyDocument("s3:GetObject", "arn:aws:s3:::second/*")
require.NoError(t, store.saveLegacyPoliciesCollection(ctx, legacyPolicies))
require.NoError(t, store.savePolicy(ctx, "first", testPolicyDocument("s3:GetObject", "arn:aws:s3:::first/*")))
require.NoError(t, store.savePolicy(ctx, "second", testPolicyDocument("s3:GetObject", "arn:aws:s3:::second/*")))
firstSaveStarted := make(chan struct{})
releaseFirstSave := make(chan struct{})
secondReachedDelete := make(chan struct{}, 1)
var blockOnce sync.Once
server.mu.Lock()
server.beforeUpdate = func(dir string, name string) error {
if dir == filer.IamConfigDirectory && name == filer.IamPoliciesFile {
blockOnce.Do(func() {
close(firstSaveStarted)
<-releaseFirstSave
})
}
return nil
}
server.beforeDelete = func(dir string, name string) error {
if dir == filer.IamConfigDirectory+"/"+IamPoliciesDirectory && name == "second.json" {
select {
case secondReachedDelete <- struct{}{}:
default:
}
}
return nil
}
server.mu.Unlock()
firstDeleteErr := make(chan error, 1)
go func() {
firstDeleteErr <- store.DeletePolicy(ctx, "first")
}()
<-firstSaveStarted
secondDeleteErr := make(chan error, 1)
go func() {
secondDeleteErr <- store.DeletePolicy(ctx, "second")
}()
select {
case <-secondReachedDelete:
t.Fatal("second delete reached filer mutation while first delete was still blocked")
case <-time.After(300 * time.Millisecond):
}
close(releaseFirstSave)
require.NoError(t, <-firstDeleteErr)
require.NoError(t, <-secondDeleteErr)
loadedLegacyPolicies, foundLegacy, err := store.loadLegacyPoliciesCollection(ctx)
require.NoError(t, err)
require.True(t, foundLegacy)
assert.Empty(t, loadedLegacyPolicies.Policies)
}
func TestFilerEtcStoreLoadManagedPoliciesRespectsReadContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
store, server := newPolicyTestStoreWithServer(t)
require.NoError(t, store.savePolicy(context.Background(), "cancel-me", testPolicyDocument("s3:GetObject", "arn:aws:s3:::cancel-me/*")))
server.mu.Lock()
server.contentlessListEntry[filerEntryKey(filer.IamConfigDirectory+"/"+IamPoliciesDirectory, "cancel-me.json")] = struct{}{}
server.beforeLookup = func(ctx context.Context, dir string, name string) error {
if dir == filer.IamConfigDirectory+"/"+IamPoliciesDirectory && name == "cancel-me.json" {
cancel()
return status.Error(codes.Canceled, context.Canceled.Error())
}
return nil
}
server.mu.Unlock()
// the canceled read must fail the snapshot, not silently omit the policy
managedPolicies, err := store.LoadManagedPolicies(ctx)
require.ErrorContains(t, err, "cancel-me.json")
assert.Empty(t, managedPolicies)
}
func testPolicyDocument(action string, resource string) policy_engine.PolicyDocument {
return policy_engine.PolicyDocument{
Version: policy_engine.PolicyVersion2012_10_17,
Statement: []policy_engine.PolicyStatement{
{
Effect: policy_engine.PolicyEffectAllow,
Action: policy_engine.NewStringOrStringSlice(action),
Resource: policy_engine.NewStringOrStringSlicePtr(resource),
},
},
}
}
func managedPolicyNames(policies []*iam_pb.Policy) []string {
names := make([]string, 0, len(policies))
for _, policy := range policies {
names = append(names, policy.Name)
}
sort.Strings(names)
return names
}
func assertInlinePolicyPreserved(t *testing.T, inlinePolicies map[string]map[string]policy_engine.PolicyDocument, userName string, policyName string) {
t.Helper()
userPolicies, found := inlinePolicies[userName]
require.True(t, found)
policy, found := userPolicies[policyName]
require.True(t, found)
assert.Equal(t, policy_engine.PolicyVersion2012_10_17, policy.Version)
require.Len(t, policy.Statement, 1)
assert.Equal(t, policy_engine.PolicyEffectAllow, policy.Statement[0].Effect)
}
func cloneEntry(entry *filer_pb.Entry) *filer_pb.Entry {
if entry == nil {
return nil
}
return proto.Clone(entry).(*filer_pb.Entry)
}
func filerEntryKey(dir string, name string) string {
return dir + "\x00" + name
}
func splitFilerEntryKey(key string) (dir string, name string) {
for idx := 0; idx < len(key); idx++ {
if key[idx] == '\x00' {
return key[:idx], key[idx+1:]
}
}
return key, ""
}

View File

@@ -0,0 +1,211 @@
package filer_etc
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func validateServiceAccountId(id string) error {
return credential.ValidateServiceAccountId(id)
}
func (store *FilerEtcStore) loadServiceAccountsFromMultiFile(ctx context.Context, s3cfg *iam_pb.S3ApiConfiguration) error {
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamServiceAccountsDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDirectory {
continue
}
if !strings.HasSuffix(entry.Name, ".json") {
continue
}
var content []byte
if len(entry.Content) > 0 {
content = entry.Content
} else {
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
if err != nil {
return fmt.Errorf("failed to read service account file %s: %w", entry.Name, err)
}
content = c
}
if len(content) == 0 {
return fmt.Errorf("service account file %s is empty", entry.Name)
}
sa := &iam_pb.ServiceAccount{}
if err := json.Unmarshal(content, sa); err != nil {
return fmt.Errorf("failed to unmarshal service account %s: %w", entry.Name, err)
}
if err := validateServiceAccountId(sa.Id); err != nil {
return fmt.Errorf("service account file %s: %w", entry.Name, err)
}
s3cfg.ServiceAccounts = append(s3cfg.ServiceAccounts, sa)
}
return nil
})
}
func (store *FilerEtcStore) saveServiceAccount(ctx context.Context, sa *iam_pb.ServiceAccount) error {
if sa == nil {
return fmt.Errorf("service account is nil")
}
if err := validateServiceAccountId(sa.Id); err != nil {
return err
}
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
data, err := json.MarshalIndent(sa, "", " ")
if err != nil {
return err
}
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory+"/"+IamServiceAccountsDirectory, sa.Id+".json", data)
})
}
func (store *FilerEtcStore) deleteServiceAccount(ctx context.Context, saId string) error {
if err := validateServiceAccountId(saId); err != nil {
return err
}
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
Directory: filer.IamConfigDirectory + "/" + IamServiceAccountsDirectory,
Name: saId + ".json",
})
if err != nil {
if strings.Contains(err.Error(), filer_pb.ErrNotFound.Error()) {
return credential.ErrServiceAccountNotFound
}
return err
}
if resp != nil && resp.Error != "" {
if strings.Contains(resp.Error, filer_pb.ErrNotFound.Error()) {
return credential.ErrServiceAccountNotFound
}
return fmt.Errorf("delete service account %s: %s", saId, resp.Error)
}
return nil
})
}
func (store *FilerEtcStore) CreateServiceAccount(ctx context.Context, sa *iam_pb.ServiceAccount) error {
existing, err := store.GetServiceAccount(ctx, sa.Id)
if err != nil {
if !errors.Is(err, credential.ErrServiceAccountNotFound) {
return err
}
} else if existing != nil {
return fmt.Errorf("service account %s already exists", sa.Id)
}
return store.saveServiceAccount(ctx, sa)
}
func (store *FilerEtcStore) UpdateServiceAccount(ctx context.Context, id string, sa *iam_pb.ServiceAccount) error {
if sa.Id != id {
return fmt.Errorf("service account ID mismatch")
}
_, err := store.GetServiceAccount(ctx, id)
if err != nil {
return err
}
return store.saveServiceAccount(ctx, sa)
}
func (store *FilerEtcStore) DeleteServiceAccount(ctx context.Context, id string) error {
return store.deleteServiceAccount(ctx, id)
}
func (store *FilerEtcStore) GetServiceAccount(ctx context.Context, id string) (*iam_pb.ServiceAccount, error) {
if err := validateServiceAccountId(id); err != nil {
return nil, err
}
var sa *iam_pb.ServiceAccount
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
data, err := filer.ReadInsideFiler(ctx, client, filer.IamConfigDirectory+"/"+IamServiceAccountsDirectory, id+".json")
if err != nil {
if err == filer_pb.ErrNotFound {
return credential.ErrServiceAccountNotFound
}
return err
}
if len(data) == 0 {
return credential.ErrServiceAccountNotFound
}
sa = &iam_pb.ServiceAccount{}
return json.Unmarshal(data, sa)
})
return sa, err
}
func (store *FilerEtcStore) ListServiceAccounts(ctx context.Context) ([]*iam_pb.ServiceAccount, error) {
var accounts []*iam_pb.ServiceAccount
err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamServiceAccountsDirectory
entries, err := listEntries(ctx, client, dir)
if err != nil {
if err == filer_pb.ErrNotFound {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDirectory {
continue
}
var content []byte
if len(entry.Content) > 0 {
content = entry.Content
} else {
c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
if err != nil {
glog.Warningf("Failed to read service account file %s: %v", entry.Name, err)
continue
}
content = c
}
if len(content) > 0 {
sa := &iam_pb.ServiceAccount{}
if err := json.Unmarshal(content, sa); err != nil {
glog.Warningf("Failed to unmarshal service account %s: %v", entry.Name, err)
continue
}
accounts = append(accounts, sa)
}
}
return nil
})
return accounts, err
}
func (store *FilerEtcStore) GetServiceAccountByAccessKey(ctx context.Context, accessKey string) (*iam_pb.ServiceAccount, error) {
accounts, err := store.ListServiceAccounts(ctx)
if err != nil {
return nil, err
}
for _, sa := range accounts {
if sa.Credential != nil && sa.Credential.AccessKey == accessKey {
return sa, nil
}
}
return nil, credential.ErrAccessKeyNotFound
}

View File

@@ -0,0 +1,79 @@
package filer_etc
import (
"fmt"
"sync"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
)
func init() {
credential.Stores = append(credential.Stores, &FilerEtcStore{})
}
// FilerEtcStore implements CredentialStore using SeaweedFS filer for storage
type FilerEtcStore struct {
filerAddressFunc func() pb.ServerAddress // Function to get current active filer
grpcDialOption grpc.DialOption
mu sync.RWMutex // Protects filerAddressFunc and grpcDialOption
policyMu sync.Mutex // Serializes legacy managed-policy mutations
}
func (store *FilerEtcStore) GetName() credential.CredentialStoreTypeName {
return credential.StoreTypeFilerEtc
}
func (store *FilerEtcStore) Initialize(configuration util.Configuration, prefix string) error {
// Handle nil configuration gracefully
if configuration != nil {
filerAddr := configuration.GetString(prefix + "filer")
if filerAddr != "" {
// Static configuration - use fixed address
store.mu.Lock()
store.filerAddressFunc = func() pb.ServerAddress {
return pb.ServerAddress(filerAddr)
}
store.mu.Unlock()
}
// TODO: Initialize grpcDialOption based on configuration
}
// Note: filerAddressFunc can be set later via SetFilerAddressFunc method
return nil
}
// SetFilerAddressFunc sets a function that returns the current active filer address
// This enables high availability by using the currently active filer
func (store *FilerEtcStore) SetFilerAddressFunc(getFiler func() pb.ServerAddress, grpcDialOption grpc.DialOption) {
store.mu.Lock()
defer store.mu.Unlock()
store.filerAddressFunc = getFiler
store.grpcDialOption = grpcDialOption
}
// withFilerClient executes a function with a filer client
func (store *FilerEtcStore) withFilerClient(fn func(client filer_pb.SeaweedFilerClient) error) error {
store.mu.RLock()
if store.filerAddressFunc == nil {
store.mu.RUnlock()
return fmt.Errorf("filer_etc: filer not yet available - please wait for filer discovery to complete and try again")
}
filerAddress := store.filerAddressFunc()
dialOption := store.grpcDialOption
store.mu.RUnlock()
if filerAddress == "" {
return fmt.Errorf("filer_etc: no filer discovered yet - please ensure a filer is running and accessible")
}
// Use the pb.WithGrpcFilerClient helper similar to existing code
return pb.WithGrpcFilerClient(false, 0, filerAddress, dialOption, fn)
}
func (store *FilerEtcStore) Shutdown() {
// No cleanup needed for file store
}

View File

@@ -0,0 +1,87 @@
package grpc
import (
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
// NOTE: The gRPC store uses a load-modify-save pattern for all operations,
// which is inherently subject to race conditions under concurrent access.
// This matches the existing pattern used for identities and policies.
// A future improvement would add dedicated gRPC RPCs for atomic group operations.
func (store *IamGrpcStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
if group == nil || group.Name == "" {
return fmt.Errorf("group name is required")
}
config, err := store.LoadConfiguration(ctx)
if err != nil {
return err
}
for _, g := range config.Groups {
if g.Name == group.Name {
return credential.ErrGroupAlreadyExists
}
}
config.Groups = append(config.Groups, group)
return store.SaveConfiguration(ctx, config)
}
func (store *IamGrpcStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return nil, err
}
for _, g := range config.Groups {
if g.Name == groupName {
return g, nil
}
}
return nil, credential.ErrGroupNotFound
}
func (store *IamGrpcStore) DeleteGroup(ctx context.Context, groupName string) error {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return err
}
for i, g := range config.Groups {
if g.Name == groupName {
config.Groups = append(config.Groups[:i], config.Groups[i+1:]...)
return store.SaveConfiguration(ctx, config)
}
}
return credential.ErrGroupNotFound
}
func (store *IamGrpcStore) ListGroups(ctx context.Context) ([]string, error) {
config, err := store.LoadConfiguration(ctx)
if err != nil {
return nil, err
}
var names []string
for _, g := range config.Groups {
names = append(names, g.Name)
}
return names, nil
}
func (store *IamGrpcStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
if group == nil || group.Name == "" {
return fmt.Errorf("group name is required")
}
config, err := store.LoadConfiguration(ctx)
if err != nil {
return err
}
for i, g := range config.Groups {
if g.Name == group.Name {
config.Groups[i] = group
return store.SaveConfiguration(ctx, config)
}
}
return credential.ErrGroupNotFound
}

View File

@@ -0,0 +1,193 @@
package grpc
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func (store *IamGrpcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
var config *iam_pb.S3ApiConfiguration
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
resp, err := client.GetConfiguration(ctx, &iam_pb.GetConfigurationRequest{})
if err != nil {
return err
}
config = resp.Configuration
return nil
})
return config, err
}
func (store *IamGrpcStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.PutConfiguration(ctx, &iam_pb.PutConfigurationRequest{
Configuration: config,
})
return err
})
}
func (store *IamGrpcStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.CreateUser(ctx, &iam_pb.CreateUserRequest{
Identity: identity,
})
return err
})
}
func (store *IamGrpcStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
var identity *iam_pb.Identity
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
resp, err := client.GetUser(ctx, &iam_pb.GetUserRequest{
Username: username,
})
if err != nil {
return err
}
identity = resp.Identity
return nil
})
// The filer-side handler returns gRPC NotFound when the user is absent;
// translate back to the package sentinel so callers can use
// errors.Is(err, credential.ErrUserNotFound) uniformly across stores.
if err != nil && status.Code(err) == codes.NotFound {
return nil, credential.ErrUserNotFound
}
return identity, err
}
func (store *IamGrpcStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.UpdateUser(ctx, &iam_pb.UpdateUserRequest{
Username: username,
Identity: identity,
})
return err
})
}
func (store *IamGrpcStore) DeleteUser(ctx context.Context, username string) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.DeleteUser(ctx, &iam_pb.DeleteUserRequest{
Username: username,
})
return err
})
}
func (store *IamGrpcStore) ListUsers(ctx context.Context) ([]string, error) {
var usernames []string
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
resp, err := client.ListUsers(ctx, &iam_pb.ListUsersRequest{})
if err != nil {
return err
}
usernames = resp.Usernames
return nil
})
return usernames, err
}
func (store *IamGrpcStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
var identity *iam_pb.Identity
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
resp, err := client.GetUserByAccessKey(ctx, &iam_pb.GetUserByAccessKeyRequest{
AccessKey: accessKey,
})
if err != nil {
return err
}
identity = resp.Identity
return nil
})
return identity, err
}
func (store *IamGrpcStore) CreateAccessKey(ctx context.Context, username string, credential *iam_pb.Credential) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.CreateAccessKey(ctx, &iam_pb.CreateAccessKeyRequest{
Username: username,
Credential: credential,
})
return err
})
}
func (store *IamGrpcStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.DeleteAccessKey(ctx, &iam_pb.DeleteAccessKeyRequest{
Username: username,
AccessKey: accessKey,
})
return err
})
}
// AttachUserPolicy attaches a managed policy to a user by policy name
func (store *IamGrpcStore) AttachUserPolicy(ctx context.Context, username string, policyName string) error {
// Get current user
identity, err := store.GetUser(ctx, username)
if err != nil {
return err
}
// Verify policy exists
policy, err := store.GetPolicy(ctx, policyName)
if err != nil {
return err
}
if policy == nil {
return credential.ErrPolicyNotFound
}
// Check if already attached
for _, p := range identity.PolicyNames {
if p == policyName {
// Already attached - return success (idempotent)
return nil
}
}
identity.PolicyNames = append(identity.PolicyNames, policyName)
return store.UpdateUser(ctx, username, identity)
}
// DetachUserPolicy detaches a managed policy from a user
func (store *IamGrpcStore) DetachUserPolicy(ctx context.Context, username string, policyName string) error {
identity, err := store.GetUser(ctx, username)
if err != nil {
return err
}
found := false
var newPolicies []string
for _, p := range identity.PolicyNames {
if p == policyName {
found = true
} else {
newPolicies = append(newPolicies, p)
}
}
if !found {
return credential.ErrPolicyNotAttached
}
identity.PolicyNames = newPolicies
return store.UpdateUser(ctx, username, identity)
}
// ListAttachedUserPolicies returns the list of policy names attached to a user
func (store *IamGrpcStore) ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error) {
identity, err := store.GetUser(ctx, username)
if err != nil {
return nil, err
}
return identity.PolicyNames, nil
}

View File

@@ -0,0 +1,101 @@
package grpc
import (
"context"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (store *IamGrpcStore) GetPolicies(ctx context.Context) (map[string]policy_engine.PolicyDocument, error) {
policies := make(map[string]policy_engine.PolicyDocument)
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
resp, err := client.ListPolicies(ctx, &iam_pb.ListPoliciesRequest{})
if err != nil {
return err
}
for _, p := range resp.Policies {
var doc policy_engine.PolicyDocument
if err := json.Unmarshal([]byte(p.Content), &doc); err != nil {
return fmt.Errorf("failed to unmarshal policy %s: %v", p.Name, err)
}
policies[p.Name] = doc
}
return nil
})
return policies, err
}
func (store *IamGrpcStore) PutPolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
content, err := json.Marshal(document)
if err != nil {
return err
}
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.PutPolicy(ctx, &iam_pb.PutPolicyRequest{
Name: name,
Content: string(content),
})
return err
})
}
func (store *IamGrpcStore) DeletePolicy(ctx context.Context, name string) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.DeletePolicy(ctx, &iam_pb.DeletePolicyRequest{
Name: name,
})
return err
})
}
func (store *IamGrpcStore) GetPolicy(ctx context.Context, name string) (*policy_engine.PolicyDocument, error) {
var doc policy_engine.PolicyDocument
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
resp, err := client.GetPolicy(ctx, &iam_pb.GetPolicyRequest{
Name: name,
})
if err != nil {
return err
}
return json.Unmarshal([]byte(resp.Content), &doc)
})
if err != nil {
// If policy not found, return nil instead of error (consistent with other stores)
if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound {
return nil, nil
}
return nil, err
}
return &doc, nil
}
// CreatePolicy creates a new policy (delegates to PutPolicy)
func (store *IamGrpcStore) CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
return store.PutPolicy(ctx, name, document)
}
// ListPolicyNames retrieves names of all IAM policies via gRPC.
func (store *IamGrpcStore) ListPolicyNames(ctx context.Context) ([]string, error) {
var names []string
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
resp, err := client.ListPolicies(ctx, &iam_pb.ListPoliciesRequest{})
if err != nil {
return err
}
for _, policy := range resp.Policies {
names = append(names, policy.Name)
}
return nil
})
return names, err
}
// UpdatePolicy updates an existing policy (delegates to PutPolicy)
func (store *IamGrpcStore) UpdatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
return store.PutPolicy(ctx, name, document)
}

View File

@@ -0,0 +1,78 @@
package grpc
import (
"context"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func (store *IamGrpcStore) CreateServiceAccount(ctx context.Context, sa *iam_pb.ServiceAccount) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.CreateServiceAccount(ctx, &iam_pb.CreateServiceAccountRequest{
ServiceAccount: sa,
})
return err
})
}
func (store *IamGrpcStore) UpdateServiceAccount(ctx context.Context, id string, sa *iam_pb.ServiceAccount) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.UpdateServiceAccount(ctx, &iam_pb.UpdateServiceAccountRequest{
Id: id,
ServiceAccount: sa,
})
return err
})
}
func (store *IamGrpcStore) DeleteServiceAccount(ctx context.Context, id string) error {
return store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
_, err := client.DeleteServiceAccount(ctx, &iam_pb.DeleteServiceAccountRequest{
Id: id,
})
return err
})
}
func (store *IamGrpcStore) GetServiceAccount(ctx context.Context, id string) (*iam_pb.ServiceAccount, error) {
var sa *iam_pb.ServiceAccount
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
resp, err := client.GetServiceAccount(ctx, &iam_pb.GetServiceAccountRequest{
Id: id,
})
if err != nil {
return err
}
sa = resp.ServiceAccount
return nil
})
return sa, err
}
func (store *IamGrpcStore) ListServiceAccounts(ctx context.Context) ([]*iam_pb.ServiceAccount, error) {
var accounts []*iam_pb.ServiceAccount
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
resp, err := client.ListServiceAccounts(ctx, &iam_pb.ListServiceAccountsRequest{})
if err != nil {
return err
}
accounts = resp.ServiceAccounts
return nil
})
return accounts, err
}
func (store *IamGrpcStore) GetServiceAccountByAccessKey(ctx context.Context, accessKey string) (*iam_pb.ServiceAccount, error) {
var sa *iam_pb.ServiceAccount
err := store.withIamClient(ctx, func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
resp, err := client.GetServiceAccountByAccessKey(ctx, &iam_pb.GetServiceAccountByAccessKeyRequest{
AccessKey: accessKey,
})
if err != nil {
return err
}
sa = resp.ServiceAccount
return nil
})
return sa, err
}

View File

@@ -0,0 +1,108 @@
package grpc
import (
"context"
"fmt"
"sync"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
func init() {
credential.Stores = append(credential.Stores, &IamGrpcStore{})
}
// IamGrpcStore implements CredentialStore by calling the filer's IAM gRPC
// service. The filer requires an admin-signed Bearer token on every RPC
// (see weed/server/filer_server_handlers_iam_grpc.go); SetAdminSigning must
// be called with the same jwt.filer_signing.key value that the filer reads
// from security.toml, or every call will fail with Unauthenticated.
type IamGrpcStore struct {
filerAddressFunc func() pb.ServerAddress // Function to get current active filer
grpcDialOption grpc.DialOption
// adminSigningKey is the HS256 secret used to mint Bearer tokens that the
// filer's IAM gRPC service validates. Must match jwt.filer_signing.key on
// the filer side. Empty means no token is sent (the filer will reject).
adminSigningKey security.SigningKey
adminSigningExpiresAfterSec int
mu sync.RWMutex // Protects filerAddressFunc, grpcDialOption, adminSigningKey, and adminSigningExpiresAfterSec
}
func (store *IamGrpcStore) GetName() credential.CredentialStoreTypeName {
return credential.StoreTypeGrpc
}
func (store *IamGrpcStore) Initialize(configuration util.Configuration, prefix string) error {
if configuration != nil {
filerAddr := configuration.GetString(prefix + "filer")
if filerAddr != "" {
store.mu.Lock()
store.filerAddressFunc = func() pb.ServerAddress {
return pb.ServerAddress(filerAddr)
}
store.mu.Unlock()
}
}
return nil
}
func (store *IamGrpcStore) SetFilerAddressFunc(getFiler func() pb.ServerAddress, grpcDialOption grpc.DialOption) {
store.mu.Lock()
defer store.mu.Unlock()
store.filerAddressFunc = getFiler
store.grpcDialOption = grpcDialOption
}
// SetAdminSigning configures the HS256 secret used to mint Bearer tokens for
// the filer's IAM gRPC service. The key must match jwt.filer_signing.key in
// the filer's security.toml. If expiresAfterSec is 0, tokens are minted
// without an exp claim.
func (store *IamGrpcStore) SetAdminSigning(key security.SigningKey, expiresAfterSec int) {
store.mu.Lock()
defer store.mu.Unlock()
store.adminSigningKey = key
store.adminSigningExpiresAfterSec = expiresAfterSec
}
// withIamClient invokes fn against a (possibly cached) gRPC client to the
// filer's IAM service. If an admin signing key is configured the call attaches
// a freshly minted Bearer token via outgoing metadata; otherwise no auth
// header is sent and the filer will return Unauthenticated.
func (store *IamGrpcStore) withIamClient(ctx context.Context, fn func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error) error {
store.mu.RLock()
if store.filerAddressFunc == nil {
store.mu.RUnlock()
return fmt.Errorf("iam_grpc: filer not yet available")
}
filerAddress := store.filerAddressFunc()
dialOption := store.grpcDialOption
signingKey := store.adminSigningKey
expiresAfterSec := store.adminSigningExpiresAfterSec
store.mu.RUnlock()
if filerAddress == "" {
return fmt.Errorf("iam_grpc: no filer discovered yet")
}
if len(signingKey) > 0 {
token := security.GenJwtForFilerAdmin(signingKey, expiresAfterSec)
if token != "" {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", security.BearerPrefix+string(token))
}
}
return pb.WithGrpcClient(context.Background(), false, 0, func(conn *grpc.ClientConn) error {
client := iam_pb.NewSeaweedIdentityAccessManagementClient(conn)
return fn(ctx, client)
}, filerAddress.ToGrpcAddress(), false, dialOption)
}
func (store *IamGrpcStore) Shutdown() {
}

View File

@@ -0,0 +1,108 @@
package grpc
import (
"context"
"fmt"
"net"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/security"
weed_server "github.com/seaweedfs/seaweedfs/weed/server"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
// TestIamGrpcStore_AdminBearerToken pins the contract that broke in 4.24:
// after PR #9442 the filer's IAM gRPC service requires an admin-signed
// Bearer token on every RPC, and IamGrpcStore is the only production
// client that talks to it (from the admin server). If the store doesn't
// mint and attach a token the admin UI Users/Groups pages fail with
// Unauthenticated; that's what issues #9495/#9496 reported.
func TestIamGrpcStore_AdminBearerToken(t *testing.T) {
const goodKey = "iam-admin-itest-key"
const wrongKey = "iam-admin-itest-key-different"
// Real IamGrpcServer backed by an in-memory credential manager so we
// exercise the full handler (auth check + business logic), not a stub.
cm, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "")
if err != nil {
t.Fatalf("NewCredentialManager: %v", err)
}
defer cm.Shutdown()
iamSrv := weed_server.NewIamGrpcServer(cm, security.SigningKey(goodKey))
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
grpcSrv := grpc.NewServer()
iam_pb.RegisterSeaweedIdentityAccessManagementServer(grpcSrv, iamSrv)
go func() { _ = grpcSrv.Serve(lis) }()
t.Cleanup(grpcSrv.Stop)
// pb.ServerAddress encodes the grpc port in the trailing ".N" segment.
// host:0 is the unused HTTP port; the listener's actual port is the
// gRPC one that ToGrpcAddress() unpacks for dialing.
_, portStr, err := net.SplitHostPort(lis.Addr().String())
if err != nil {
t.Fatalf("split listener addr: %v", err)
}
serverAddr := pb.ServerAddress(fmt.Sprintf("127.0.0.1:0.%s", portStr))
newStore := func(key string) *IamGrpcStore {
s := &IamGrpcStore{}
s.SetFilerAddressFunc(func() pb.ServerAddress { return serverAddr }, grpc.WithTransportCredentials(insecure.NewCredentials()))
if key != "" {
s.SetAdminSigning(security.SigningKey(key), 0)
}
return s
}
ctx := context.Background()
t.Run("matching_key_succeeds", func(t *testing.T) {
store := newStore(goodKey)
if _, err := store.ListUsers(ctx); err != nil {
t.Fatalf("ListUsers with matching key: %v", err)
}
if err := store.CreateUser(ctx, &iam_pb.Identity{Name: "alice"}); err != nil {
t.Fatalf("CreateUser with matching key: %v", err)
}
got, err := store.GetUser(ctx, "alice")
if err != nil {
t.Fatalf("GetUser with matching key: %v", err)
}
if got == nil || got.Name != "alice" {
t.Fatalf("GetUser returned %+v, want name=alice", got)
}
})
t.Run("wrong_key_unauthenticated", func(t *testing.T) {
store := newStore(wrongKey)
_, err := store.ListUsers(ctx)
if got := status.Code(err); got != codes.Unauthenticated {
t.Fatalf("ListUsers with wrong key: got code %v err=%v, want Unauthenticated", got, err)
}
})
t.Run("no_key_unauthenticated", func(t *testing.T) {
store := newStore("")
_, err := store.ListUsers(ctx)
// Server reports "missing authorization metadata" when no token is
// attached. Either way the gRPC status code is Unauthenticated.
if got := status.Code(err); got != codes.Unauthenticated {
t.Fatalf("ListUsers with no key: got code %v err=%v, want Unauthenticated", got, err)
}
if err != nil && !strings.Contains(err.Error(), "authorization") {
t.Fatalf("ListUsers with no key: error message %q does not mention authorization", err)
}
})
}

View File

@@ -0,0 +1,89 @@
package memory
import (
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
// cloneGroup creates a deep copy of an iam_pb.Group.
func cloneGroup(g *iam_pb.Group) *iam_pb.Group {
if g == nil {
return nil
}
clone := &iam_pb.Group{
Name: g.Name,
Disabled: g.Disabled,
}
if g.Members != nil {
clone.Members = make([]string, len(g.Members))
copy(clone.Members, g.Members)
}
if g.PolicyNames != nil {
clone.PolicyNames = make([]string, len(g.PolicyNames))
copy(clone.PolicyNames, g.PolicyNames)
}
return clone
}
func (store *MemoryStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
if group == nil || group.Name == "" {
return fmt.Errorf("group name is required")
}
store.mu.Lock()
defer store.mu.Unlock()
if _, exists := store.groups[group.Name]; exists {
return credential.ErrGroupAlreadyExists
}
store.groups[group.Name] = cloneGroup(group)
return nil
}
func (store *MemoryStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if g, exists := store.groups[groupName]; exists {
return cloneGroup(g), nil
}
return nil, credential.ErrGroupNotFound
}
func (store *MemoryStore) DeleteGroup(ctx context.Context, groupName string) error {
store.mu.Lock()
defer store.mu.Unlock()
if _, exists := store.groups[groupName]; !exists {
return credential.ErrGroupNotFound
}
delete(store.groups, groupName)
return nil
}
func (store *MemoryStore) ListGroups(ctx context.Context) ([]string, error) {
store.mu.RLock()
defer store.mu.RUnlock()
var names []string
for name := range store.groups {
names = append(names, name)
}
return names, nil
}
func (store *MemoryStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
if group == nil || group.Name == "" {
return fmt.Errorf("group name is required")
}
store.mu.Lock()
defer store.mu.Unlock()
if _, exists := store.groups[group.Name]; !exists {
return credential.ErrGroupNotFound
}
store.groups[group.Name] = cloneGroup(group)
return nil
}

View File

@@ -0,0 +1,451 @@
package memory
import (
"context"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
)
func (store *MemoryStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
config := &iam_pb.S3ApiConfiguration{}
// Convert all users to identities
for _, user := range store.users {
config.Identities = append(config.Identities, store.deepCopyIdentity(user))
}
// Add all policies
for name, doc := range store.policies {
content, _ := json.Marshal(doc)
config.Policies = append(config.Policies, &iam_pb.Policy{
Name: name,
Content: string(content),
})
}
// Groups are written via CreateGroup / AddUserToGroup / AttachGroupPolicy
// (never via SaveConfiguration), so they live in store.groups regardless
// of the bulk-config path. Surface them here so consumers that reload
// through cm.LoadConfiguration see a faithful snapshot — without this,
// iam.groups would never repopulate on memory-store reloads and group
// policy evaluation would silently no-op.
for _, g := range store.groups {
config.Groups = append(config.Groups, cloneGroup(g))
}
return config, nil
}
func (store *MemoryStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
// Clear existing data
store.users = make(map[string]*iam_pb.Identity)
store.accessKeys = make(map[string]string)
store.policies = make(map[string]policy_engine.PolicyDocument)
// Add all identities
for _, identity := range config.Identities {
store.users[identity.Name] = store.deepCopyIdentity(identity)
// Index access keys
for _, credential := range identity.Credentials {
store.accessKeys[credential.AccessKey] = identity.Name
}
}
// Add all policies
for _, policy := range config.Policies {
var doc policy_engine.PolicyDocument
if err := json.Unmarshal([]byte(policy.Content), &doc); err == nil {
store.policies[policy.Name] = doc
}
}
return nil
}
func (store *MemoryStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
if _, exists := store.users[identity.Name]; exists {
return credential.ErrUserAlreadyExists
}
// Check for duplicate access keys
for _, cred := range identity.Credentials {
if _, exists := store.accessKeys[cred.AccessKey]; exists {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
}
// Deep copy to avoid mutation issues
identityCopy := store.deepCopyIdentity(identity)
store.users[identity.Name] = identityCopy
// Index access keys
for _, cred := range identity.Credentials {
store.accessKeys[cred.AccessKey] = identity.Name
}
return nil
}
func (store *MemoryStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return nil, credential.ErrUserNotFound
}
// Return a deep copy to avoid mutation issues
return store.deepCopyIdentity(user), nil
}
func (store *MemoryStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
existingUser, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Remove old access keys from index
for _, cred := range existingUser.Credentials {
delete(store.accessKeys, cred.AccessKey)
}
// Check for duplicate access keys (excluding current user)
for _, cred := range identity.Credentials {
if existingUsername, exists := store.accessKeys[cred.AccessKey]; exists && existingUsername != username {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
}
// Deep copy to avoid mutation issues
identityCopy := store.deepCopyIdentity(identity)
store.users[username] = identityCopy
// Re-index access keys
for _, cred := range identity.Credentials {
store.accessKeys[cred.AccessKey] = username
}
return nil
}
func (store *MemoryStore) DeleteUser(ctx context.Context, username string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Remove access keys from index
for _, cred := range user.Credentials {
delete(store.accessKeys, cred.AccessKey)
}
// Remove user
delete(store.users, username)
delete(store.inlinePolicies, username)
return nil
}
// RenameUser implements credential.UserRenamer for the memory store.
// Renames the user and re-points access-key and inline-policy indexes
// to the new name. The whole operation is performed under the store
// lock so it is atomic with respect to concurrent reads.
func (store *MemoryStore) RenameUser(ctx context.Context, oldName, newName string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
if oldName == newName {
return nil
}
user, exists := store.users[oldName]
if !exists {
return credential.ErrUserNotFound
}
if _, clash := store.users[newName]; clash {
return credential.ErrUserAlreadyExists
}
user.Name = newName
store.users[newName] = user
delete(store.users, oldName)
for _, cred := range user.Credentials {
store.accessKeys[cred.AccessKey] = newName
}
if policies, ok := store.inlinePolicies[oldName]; ok {
store.inlinePolicies[newName] = policies
delete(store.inlinePolicies, oldName)
}
return nil
}
func (store *MemoryStore) ListUsers(ctx context.Context) ([]string, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
var usernames []string
for username := range store.users {
usernames = append(usernames, username)
}
return usernames, nil
}
func (store *MemoryStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
username, exists := store.accessKeys[accessKey]
if !exists {
return nil, credential.ErrAccessKeyNotFound
}
user, exists := store.users[username]
if !exists {
// This should not happen, but handle it gracefully
return nil, credential.ErrUserNotFound
}
// Return a deep copy to avoid mutation issues
return store.deepCopyIdentity(user), nil
}
func (store *MemoryStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Check if access key already exists
if _, exists := store.accessKeys[cred.AccessKey]; exists {
return fmt.Errorf("access key %s already exists", cred.AccessKey)
}
// Add credential to user
user.Credentials = append(user.Credentials, &iam_pb.Credential{
AccessKey: cred.AccessKey,
SecretKey: cred.SecretKey,
})
// Index the access key
store.accessKeys[cred.AccessKey] = username
return nil
}
func (store *MemoryStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Find and remove the credential
var newCredentials []*iam_pb.Credential
found := false
for _, cred := range user.Credentials {
if cred.AccessKey == accessKey {
found = true
// Remove from access key index
delete(store.accessKeys, accessKey)
} else {
newCredentials = append(newCredentials, cred)
}
}
if !found {
return credential.ErrAccessKeyNotFound
}
user.Credentials = newCredentials
return nil
}
// deepCopyIdentity creates a deep copy of an identity to avoid mutation issues
func (store *MemoryStore) deepCopyIdentity(identity *iam_pb.Identity) *iam_pb.Identity {
if identity == nil {
return nil
}
// Use JSON marshaling/unmarshaling for deep copy
// This is simple and safe for protobuf messages
data, err := json.Marshal(identity)
if err != nil {
// Fallback to shallow copy if JSON fails
return &iam_pb.Identity{
Name: identity.Name,
Account: identity.Account,
Credentials: identity.Credentials,
Actions: identity.Actions,
PolicyNames: identity.PolicyNames,
}
}
var copy iam_pb.Identity
if err := json.Unmarshal(data, &copy); err != nil {
// Fallback to shallow copy if JSON fails
return &iam_pb.Identity{
Name: identity.Name,
Account: identity.Account,
Credentials: identity.Credentials,
Actions: identity.Actions,
PolicyNames: identity.PolicyNames,
}
}
return &copy
}
// AttachUserPolicy attaches a managed policy to a user by policy name
func (store *MemoryStore) AttachUserPolicy(ctx context.Context, username string, policyName string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
// Verify policy exists
if _, exists := store.policies[policyName]; !exists {
return credential.ErrPolicyNotFound
}
// Check if already attached
for _, p := range user.PolicyNames {
if p == policyName {
return credential.ErrPolicyAlreadyAttached
}
}
user.PolicyNames = append(user.PolicyNames, policyName)
return nil
}
// DetachUserPolicy detaches a managed policy from a user
func (store *MemoryStore) DetachUserPolicy(ctx context.Context, username string, policyName string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return credential.ErrUserNotFound
}
found := false
var newPolicies []string
for _, p := range user.PolicyNames {
if p == policyName {
found = true
} else {
newPolicies = append(newPolicies, p)
}
}
if !found {
return credential.ErrPolicyNotAttached
}
user.PolicyNames = newPolicies
return nil
}
// ListAttachedUserPolicies returns the list of policy names attached to a user
func (store *MemoryStore) ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
user, exists := store.users[username]
if !exists {
return nil, credential.ErrUserNotFound
}
// Return copy to prevent mutation
result := make([]string, len(user.PolicyNames))
copy(result, user.PolicyNames)
return result, nil
}

View File

@@ -0,0 +1,283 @@
package memory
import (
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
)
// GetPolicies retrieves all IAM policies from memory
func (store *MemoryStore) GetPolicies(ctx context.Context) (map[string]policy_engine.PolicyDocument, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
// Create a copy of the policies map to avoid mutation issues
policies := make(map[string]policy_engine.PolicyDocument)
for name, doc := range store.policies {
policies[name] = doc
}
return policies, nil
}
// ListPolicyNames returns all stored policy names.
func (store *MemoryStore) ListPolicyNames(ctx context.Context) ([]string, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
names := make([]string, 0, len(store.policies))
for name := range store.policies {
names = append(names, name)
}
return names, nil
}
// GetPolicy retrieves a specific IAM policy by name from memory
func (store *MemoryStore) GetPolicy(ctx context.Context, name string) (*policy_engine.PolicyDocument, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if policy, exists := store.policies[name]; exists {
return &policy, nil
}
return nil, nil // Policy not found
}
// CreatePolicy creates a new IAM policy in memory
func (store *MemoryStore) CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
store.policies[name] = document
return nil
}
// UpdatePolicy updates an existing IAM policy in memory
func (store *MemoryStore) UpdatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
store.policies[name] = document
return nil
}
// PutPolicy creates or updates an IAM policy in memory
func (store *MemoryStore) PutPolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
store.policies[name] = document
return nil
}
// DeletePolicy deletes an IAM policy from memory
func (store *MemoryStore) DeletePolicy(ctx context.Context, name string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
delete(store.policies, name)
return nil
}
// PutUserInlinePolicy stores a per-user inline policy document.
func (store *MemoryStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
if store.inlinePolicies[userName] == nil {
store.inlinePolicies[userName] = make(map[string]policy_engine.PolicyDocument)
}
store.inlinePolicies[userName][policyName] = document
return nil
}
// GetUserInlinePolicy retrieves a per-user inline policy document.
func (store *MemoryStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
if userPolicies := store.inlinePolicies[userName]; userPolicies != nil {
if doc, exists := userPolicies[policyName]; exists {
return &doc, nil
}
}
return nil, nil
}
// DeleteUserInlinePolicy removes a per-user inline policy document.
func (store *MemoryStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
if userPolicies := store.inlinePolicies[userName]; userPolicies != nil {
delete(userPolicies, policyName)
if len(userPolicies) == 0 {
delete(store.inlinePolicies, userName)
}
}
return nil
}
// ListUserInlinePolicies returns the names of all inline policies for a user.
func (store *MemoryStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
userPolicies := store.inlinePolicies[userName]
names := make([]string, 0, len(userPolicies))
for name := range userPolicies {
names = append(names, name)
}
return names, nil
}
// LoadInlinePolicies returns all inline policies keyed by username then policy name.
func (store *MemoryStore) LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
result := make(map[string]map[string]policy_engine.PolicyDocument, len(store.inlinePolicies))
for userName, userPolicies := range store.inlinePolicies {
copied := make(map[string]policy_engine.PolicyDocument, len(userPolicies))
for policyName, doc := range userPolicies {
copied[policyName] = doc
}
result[userName] = copied
}
return result, nil
}
// PutGroupInlinePolicy stores a per-group inline policy document.
func (store *MemoryStore) PutGroupInlinePolicy(ctx context.Context, groupName, policyName string, document policy_engine.PolicyDocument) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
if store.groupInlinePolicies[groupName] == nil {
store.groupInlinePolicies[groupName] = make(map[string]policy_engine.PolicyDocument)
}
store.groupInlinePolicies[groupName][policyName] = document
return nil
}
// GetGroupInlinePolicy retrieves a per-group inline policy document.
func (store *MemoryStore) GetGroupInlinePolicy(ctx context.Context, groupName, policyName string) (*policy_engine.PolicyDocument, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
if groupPolicies := store.groupInlinePolicies[groupName]; groupPolicies != nil {
if doc, exists := groupPolicies[policyName]; exists {
return &doc, nil
}
}
return nil, nil
}
// DeleteGroupInlinePolicy removes a per-group inline policy document.
func (store *MemoryStore) DeleteGroupInlinePolicy(ctx context.Context, groupName, policyName string) error {
store.mu.Lock()
defer store.mu.Unlock()
if !store.initialized {
return fmt.Errorf("store not initialized")
}
if groupPolicies := store.groupInlinePolicies[groupName]; groupPolicies != nil {
delete(groupPolicies, policyName)
if len(groupPolicies) == 0 {
delete(store.groupInlinePolicies, groupName)
}
}
return nil
}
// ListGroupInlinePolicies returns the names of all inline policies for a group.
func (store *MemoryStore) ListGroupInlinePolicies(ctx context.Context, groupName string) ([]string, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
groupPolicies := store.groupInlinePolicies[groupName]
names := make([]string, 0, len(groupPolicies))
for name := range groupPolicies {
names = append(names, name)
}
return names, nil
}
// LoadGroupInlinePolicies returns all group inline policies keyed by group name then policy name.
func (store *MemoryStore) LoadGroupInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if !store.initialized {
return nil, fmt.Errorf("store not initialized")
}
result := make(map[string]map[string]policy_engine.PolicyDocument, len(store.groupInlinePolicies))
for groupName, groupPolicies := range store.groupInlinePolicies {
copied := make(map[string]policy_engine.PolicyDocument, len(groupPolicies))
for policyName, doc := range groupPolicies {
copied[policyName] = doc
}
result[groupName] = copied
}
return result, nil
}

View File

@@ -0,0 +1,85 @@
package memory
import (
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func (store *MemoryStore) CreateServiceAccount(ctx context.Context, sa *iam_pb.ServiceAccount) error {
store.mu.Lock()
defer store.mu.Unlock()
if _, exists := store.serviceAccounts[sa.Id]; exists {
return fmt.Errorf("service account already exists")
}
store.serviceAccounts[sa.Id] = sa
if sa.Credential != nil && sa.Credential.AccessKey != "" {
store.serviceAccountAccessKeys[sa.Credential.AccessKey] = sa.Id
}
return nil
}
func (store *MemoryStore) UpdateServiceAccount(ctx context.Context, id string, sa *iam_pb.ServiceAccount) error {
store.mu.Lock()
defer store.mu.Unlock()
_, exists := store.serviceAccounts[id]
if !exists {
return credential.ErrServiceAccountNotFound
}
if sa.Id != id {
return fmt.Errorf("service account ID mismatch")
}
// Update access key index: remove any existing keys for this SA
for k, v := range store.serviceAccountAccessKeys {
if v == id {
delete(store.serviceAccountAccessKeys, k)
}
}
store.serviceAccounts[id] = sa
if sa.Credential != nil && sa.Credential.AccessKey != "" {
store.serviceAccountAccessKeys[sa.Credential.AccessKey] = sa.Id
}
return nil
}
func (store *MemoryStore) DeleteServiceAccount(ctx context.Context, id string) error {
store.mu.Lock()
defer store.mu.Unlock()
if sa, ok := store.serviceAccounts[id]; ok {
if sa.Credential != nil && sa.Credential.AccessKey != "" {
delete(store.serviceAccountAccessKeys, sa.Credential.AccessKey)
}
delete(store.serviceAccounts, id)
return nil
}
return credential.ErrServiceAccountNotFound
}
func (store *MemoryStore) GetServiceAccount(ctx context.Context, id string) (*iam_pb.ServiceAccount, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if sa, exists := store.serviceAccounts[id]; exists {
return sa, nil
}
return nil, credential.ErrServiceAccountNotFound
}
func (store *MemoryStore) ListServiceAccounts(ctx context.Context) ([]*iam_pb.ServiceAccount, error) {
store.mu.RLock()
defer store.mu.RUnlock()
var accounts []*iam_pb.ServiceAccount
for _, sa := range store.serviceAccounts {
accounts = append(accounts, sa)
}
return accounts, nil
}

View File

@@ -0,0 +1,112 @@
package memory
import (
"context"
"sync"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func init() {
credential.Stores = append(credential.Stores, &MemoryStore{})
}
// MemoryStore implements CredentialStore using in-memory storage
// This is primarily intended for testing purposes
type MemoryStore struct {
mu sync.RWMutex
users map[string]*iam_pb.Identity // username -> identity
accessKeys map[string]string // access_key -> username
serviceAccounts map[string]*iam_pb.ServiceAccount // id -> service_account
serviceAccountAccessKeys map[string]string // access_key -> id
policies map[string]policy_engine.PolicyDocument // policy_name -> policy_document
inlinePolicies map[string]map[string]policy_engine.PolicyDocument // username -> policy_name -> document
groupInlinePolicies map[string]map[string]policy_engine.PolicyDocument // group_name -> policy_name -> document
groups map[string]*iam_pb.Group // group_name -> group
initialized bool
}
func (store *MemoryStore) GetName() credential.CredentialStoreTypeName {
return credential.StoreTypeMemory
}
func (store *MemoryStore) Initialize(configuration util.Configuration, prefix string) error {
store.mu.Lock()
defer store.mu.Unlock()
if store.initialized {
return nil
}
store.users = make(map[string]*iam_pb.Identity)
store.accessKeys = make(map[string]string)
store.serviceAccounts = make(map[string]*iam_pb.ServiceAccount)
store.serviceAccountAccessKeys = make(map[string]string)
store.policies = make(map[string]policy_engine.PolicyDocument)
store.inlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument)
store.groupInlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument)
store.groups = make(map[string]*iam_pb.Group)
store.initialized = true
return nil
}
func (store *MemoryStore) Shutdown() {
store.mu.Lock()
defer store.mu.Unlock()
store.users = nil
store.accessKeys = nil
store.serviceAccounts = nil
store.serviceAccountAccessKeys = nil
store.policies = nil
store.inlinePolicies = nil
store.groupInlinePolicies = nil
store.groups = nil
store.initialized = false
}
// Reset clears all data in the store (useful for testing)
func (store *MemoryStore) Reset() {
store.mu.Lock()
defer store.mu.Unlock()
if store.initialized {
store.users = make(map[string]*iam_pb.Identity)
store.accessKeys = make(map[string]string)
store.serviceAccounts = make(map[string]*iam_pb.ServiceAccount)
store.serviceAccountAccessKeys = make(map[string]string)
store.policies = make(map[string]policy_engine.PolicyDocument)
store.inlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument)
store.groupInlinePolicies = make(map[string]map[string]policy_engine.PolicyDocument)
store.groups = make(map[string]*iam_pb.Group)
}
}
// GetUserCount returns the number of users in the store (useful for testing)
func (store *MemoryStore) GetUserCount() int {
store.mu.RLock()
defer store.mu.RUnlock()
return len(store.users)
}
// GetAccessKeyCount returns the number of access keys in the store (useful for testing)
func (store *MemoryStore) GetAccessKeyCount() int {
store.mu.RLock()
defer store.mu.RUnlock()
return len(store.accessKeys)
}
func (store *MemoryStore) GetServiceAccountByAccessKey(ctx context.Context, accessKey string) (*iam_pb.ServiceAccount, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if id, ok := store.serviceAccountAccessKeys[accessKey]; ok {
return store.serviceAccounts[id], nil
}
return nil, credential.ErrAccessKeyNotFound
}

View File

@@ -0,0 +1,380 @@
package memory
import (
"context"
"fmt"
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func TestMemoryStore(t *testing.T) {
store := &MemoryStore{}
// Test initialization
config := util.GetViper()
if err := store.Initialize(config, "credential."); err != nil {
t.Fatalf("Failed to initialize store: %v", err)
}
ctx := context.Background()
// Test creating a user
identity := &iam_pb.Identity{
Name: "testuser",
Credentials: []*iam_pb.Credential{
{
AccessKey: "access123",
SecretKey: "secret123",
},
},
}
if err := store.CreateUser(ctx, identity); err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Test getting user
retrievedUser, err := store.GetUser(ctx, "testuser")
if err != nil {
t.Fatalf("Failed to get user: %v", err)
}
if retrievedUser.Name != "testuser" {
t.Errorf("Expected username 'testuser', got '%s'", retrievedUser.Name)
}
if len(retrievedUser.Credentials) != 1 {
t.Errorf("Expected 1 credential, got %d", len(retrievedUser.Credentials))
}
// Test getting user by access key
userByAccessKey, err := store.GetUserByAccessKey(ctx, "access123")
if err != nil {
t.Fatalf("Failed to get user by access key: %v", err)
}
if userByAccessKey.Name != "testuser" {
t.Errorf("Expected username 'testuser', got '%s'", userByAccessKey.Name)
}
// Test listing users
users, err := store.ListUsers(ctx)
if err != nil {
t.Fatalf("Failed to list users: %v", err)
}
if len(users) != 1 || users[0] != "testuser" {
t.Errorf("Expected ['testuser'], got %v", users)
}
// Test creating access key
newCred := &iam_pb.Credential{
AccessKey: "access456",
SecretKey: "secret456",
}
if err := store.CreateAccessKey(ctx, "testuser", newCred); err != nil {
t.Fatalf("Failed to create access key: %v", err)
}
// Verify user now has 2 credentials
updatedUser, err := store.GetUser(ctx, "testuser")
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if len(updatedUser.Credentials) != 2 {
t.Errorf("Expected 2 credentials, got %d", len(updatedUser.Credentials))
}
// Test deleting access key
if err := store.DeleteAccessKey(ctx, "testuser", "access456"); err != nil {
t.Fatalf("Failed to delete access key: %v", err)
}
// Verify user now has 1 credential again
finalUser, err := store.GetUser(ctx, "testuser")
if err != nil {
t.Fatalf("Failed to get final user: %v", err)
}
if len(finalUser.Credentials) != 1 {
t.Errorf("Expected 1 credential, got %d", len(finalUser.Credentials))
}
// Test deleting user
if err := store.DeleteUser(ctx, "testuser"); err != nil {
t.Fatalf("Failed to delete user: %v", err)
}
// Verify user is gone
_, err = store.GetUser(ctx, "testuser")
if err != credential.ErrUserNotFound {
t.Errorf("Expected ErrUserNotFound, got %v", err)
}
// Test error cases
if err := store.CreateUser(ctx, identity); err != nil {
t.Fatalf("Failed to create user for error tests: %v", err)
}
// Try to create duplicate user
if err := store.CreateUser(ctx, identity); err != credential.ErrUserAlreadyExists {
t.Errorf("Expected ErrUserAlreadyExists, got %v", err)
}
// Try to get non-existent user
_, err = store.GetUser(ctx, "nonexistent")
if err != credential.ErrUserNotFound {
t.Errorf("Expected ErrUserNotFound, got %v", err)
}
// Try to get user by non-existent access key
_, err = store.GetUserByAccessKey(ctx, "nonexistent")
if err != credential.ErrAccessKeyNotFound {
t.Errorf("Expected ErrAccessKeyNotFound, got %v", err)
}
}
func TestMemoryStoreConcurrency(t *testing.T) {
store := &MemoryStore{}
config := util.GetViper()
if err := store.Initialize(config, "credential."); err != nil {
t.Fatalf("Failed to initialize store: %v", err)
}
ctx := context.Background()
// Test concurrent access
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func(i int) {
defer func() { done <- true }()
username := fmt.Sprintf("user%d", i)
identity := &iam_pb.Identity{
Name: username,
Credentials: []*iam_pb.Credential{
{
AccessKey: fmt.Sprintf("access%d", i),
SecretKey: fmt.Sprintf("secret%d", i),
},
},
}
if err := store.CreateUser(ctx, identity); err != nil {
t.Errorf("Failed to create user %s: %v", username, err)
return
}
if _, err := store.GetUser(ctx, username); err != nil {
t.Errorf("Failed to get user %s: %v", username, err)
return
}
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < 10; i++ {
<-done
}
// Verify all users were created
users, err := store.ListUsers(ctx)
if err != nil {
t.Fatalf("Failed to list users: %v", err)
}
if len(users) != 10 {
t.Errorf("Expected 10 users, got %d", len(users))
}
}
func TestMemoryStoreReset(t *testing.T) {
store := &MemoryStore{}
config := util.GetViper()
if err := store.Initialize(config, "credential."); err != nil {
t.Fatalf("Failed to initialize store: %v", err)
}
ctx := context.Background()
// Create a user
identity := &iam_pb.Identity{
Name: "testuser",
Credentials: []*iam_pb.Credential{
{
AccessKey: "access123",
SecretKey: "secret123",
},
},
}
if err := store.CreateUser(ctx, identity); err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Verify user exists
if store.GetUserCount() != 1 {
t.Errorf("Expected 1 user, got %d", store.GetUserCount())
}
if store.GetAccessKeyCount() != 1 {
t.Errorf("Expected 1 access key, got %d", store.GetAccessKeyCount())
}
// Reset the store
store.Reset()
// Verify store is empty
if store.GetUserCount() != 0 {
t.Errorf("Expected 0 users after reset, got %d", store.GetUserCount())
}
if store.GetAccessKeyCount() != 0 {
t.Errorf("Expected 0 access keys after reset, got %d", store.GetAccessKeyCount())
}
// Verify user is gone
_, err := store.GetUser(ctx, "testuser")
if err != credential.ErrUserNotFound {
t.Errorf("Expected ErrUserNotFound after reset, got %v", err)
}
}
func TestMemoryStoreConfigurationSaveLoad(t *testing.T) {
store := &MemoryStore{}
config := util.GetViper()
if err := store.Initialize(config, "credential."); err != nil {
t.Fatalf("Failed to initialize store: %v", err)
}
ctx := context.Background()
// Create initial configuration
originalConfig := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: "user1",
Credentials: []*iam_pb.Credential{
{
AccessKey: "access1",
SecretKey: "secret1",
},
},
},
{
Name: "user2",
Credentials: []*iam_pb.Credential{
{
AccessKey: "access2",
SecretKey: "secret2",
},
},
},
},
}
// Save configuration
if err := store.SaveConfiguration(ctx, originalConfig); err != nil {
t.Fatalf("Failed to save configuration: %v", err)
}
// Load configuration
loadedConfig, err := store.LoadConfiguration(ctx)
if err != nil {
t.Fatalf("Failed to load configuration: %v", err)
}
// Verify configuration matches
if len(loadedConfig.Identities) != 2 {
t.Errorf("Expected 2 identities, got %d", len(loadedConfig.Identities))
}
// Check users exist
user1, err := store.GetUser(ctx, "user1")
if err != nil {
t.Fatalf("Failed to get user1: %v", err)
}
if len(user1.Credentials) != 1 || user1.Credentials[0].AccessKey != "access1" {
t.Errorf("User1 credentials not correct: %+v", user1.Credentials)
}
user2, err := store.GetUser(ctx, "user2")
if err != nil {
t.Fatalf("Failed to get user2: %v", err)
}
if len(user2.Credentials) != 1 || user2.Credentials[0].AccessKey != "access2" {
t.Errorf("User2 credentials not correct: %+v", user2.Credentials)
}
}
func TestMemoryStoreServiceAccountByAccessKey(t *testing.T) {
store := &MemoryStore{}
config := util.GetViper()
if err := store.Initialize(config, "credential."); err != nil {
t.Fatalf("Failed to initialize store: %v", err)
}
ctx := context.Background()
// 1. Create service account
sa := &iam_pb.ServiceAccount{
Id: "sa-test-1",
ParentUser: "user1",
Credential: &iam_pb.Credential{
AccessKey: "ACCESS-KEY-1",
SecretKey: "SECRET-KEY-1",
},
}
if err := store.CreateServiceAccount(ctx, sa); err != nil {
t.Fatalf("Failed to create service account: %v", err)
}
// 2. Lookup by access key
found, err := store.GetServiceAccountByAccessKey(ctx, "ACCESS-KEY-1")
if err != nil {
t.Fatalf("Failed to lookup by access key: %v", err)
}
if found.Id != "sa-test-1" {
t.Errorf("Expected sa-test-1, got %s", found.Id)
}
// 3. Update with new access key
sa.Credential.AccessKey = "ACCESS-KEY-2"
if err := store.UpdateServiceAccount(ctx, sa.Id, sa); err != nil {
t.Fatalf("Failed to update service account: %v", err)
}
// Verify old key is gone
_, err = store.GetServiceAccountByAccessKey(ctx, "ACCESS-KEY-1")
if err != credential.ErrAccessKeyNotFound {
t.Errorf("Expected ErrAccessKeyNotFound for old key, got %v", err)
}
// Verify new key works
found, err = store.GetServiceAccountByAccessKey(ctx, "ACCESS-KEY-2")
if err != nil {
t.Fatalf("Failed to lookup by new access key: %v", err)
}
if found.Id != "sa-test-1" {
t.Errorf("Expected sa-test-1, got %s", found.Id)
}
// 4. Delete service account
if err := store.DeleteServiceAccount(ctx, sa.Id); err != nil {
t.Fatalf("Failed to delete service account: %v", err)
}
// Verify key is gone
_, err = store.GetServiceAccountByAccessKey(ctx, "ACCESS-KEY-2")
if err != credential.ErrAccessKeyNotFound {
t.Errorf("Expected ErrAccessKeyNotFound after delete, got %v", err)
}
}

View File

@@ -0,0 +1,127 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func (store *PostgresStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
if group == nil || group.Name == "" {
return fmt.Errorf("group name is required")
}
membersJSON, err := json.Marshal(group.Members)
if err != nil {
return fmt.Errorf("failed to marshal members: %w", err)
}
policyNamesJSON, err := json.Marshal(group.PolicyNames)
if err != nil {
return fmt.Errorf("failed to marshal policy_names: %w", err)
}
_, err = store.db.ExecContext(ctx,
`INSERT INTO groups (name, members, policy_names, disabled) VALUES ($1, $2, $3, $4)`,
group.Name, jsonbParam(membersJSON), jsonbParam(policyNamesJSON), group.Disabled)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return credential.ErrGroupAlreadyExists
}
return fmt.Errorf("failed to create group: %w", err)
}
return nil
}
func (store *PostgresStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
var membersJSON, policyNamesJSON []byte
var disabled bool
err := store.db.QueryRowContext(ctx,
`SELECT members, policy_names, disabled FROM groups WHERE name = $1`, groupName).
Scan(&membersJSON, &policyNamesJSON, &disabled)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, credential.ErrGroupNotFound
}
return nil, fmt.Errorf("failed to get group: %w", err)
}
group := &iam_pb.Group{
Name: groupName,
Disabled: disabled,
}
if err := json.Unmarshal(membersJSON, &group.Members); err != nil {
return nil, fmt.Errorf("failed to unmarshal members: %w", err)
}
if err := json.Unmarshal(policyNamesJSON, &group.PolicyNames); err != nil {
return nil, fmt.Errorf("failed to unmarshal policy_names: %w", err)
}
return group, nil
}
func (store *PostgresStore) DeleteGroup(ctx context.Context, groupName string) error {
result, err := store.db.ExecContext(ctx, `DELETE FROM groups WHERE name = $1`, groupName)
if err != nil {
return fmt.Errorf("failed to delete group: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
return credential.ErrGroupNotFound
}
return nil
}
func (store *PostgresStore) ListGroups(ctx context.Context) ([]string, error) {
rows, err := store.db.QueryContext(ctx, `SELECT name FROM groups ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("failed to list groups: %w", err)
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("failed to scan group name: %w", err)
}
names = append(names, name)
}
return names, rows.Err()
}
func (store *PostgresStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
if group == nil || group.Name == "" {
return fmt.Errorf("group name is required")
}
membersJSON, err := json.Marshal(group.Members)
if err != nil {
return fmt.Errorf("failed to marshal members: %w", err)
}
policyNamesJSON, err := json.Marshal(group.PolicyNames)
if err != nil {
return fmt.Errorf("failed to marshal policy_names: %w", err)
}
result, err := store.db.ExecContext(ctx,
`UPDATE groups SET members = $1, policy_names = $2, disabled = $3, updated_at = CURRENT_TIMESTAMP WHERE name = $4`,
jsonbParam(membersJSON), jsonbParam(policyNamesJSON), group.Disabled, group.Name)
if err != nil {
return fmt.Errorf("failed to update group: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
return credential.ErrGroupNotFound
}
return nil
}

View File

@@ -0,0 +1,149 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
)
func (store *PostgresStore) PutGroupInlinePolicy(ctx context.Context, groupName, policyName string, document policy_engine.PolicyDocument) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
docJSON, err := json.Marshal(document)
if err != nil {
glog.Errorf("credential postgres: PutGroupInlinePolicy marshal failed group=%s policy=%s: %v", groupName, policyName, err)
return fmt.Errorf("failed to marshal policy document: %w", err)
}
_, err = store.db.ExecContext(ctx,
`INSERT INTO group_inline_policies (group_name, policy_name, document)
VALUES ($1, $2, $3)
ON CONFLICT (group_name, policy_name)
DO UPDATE SET document = $3, updated_at = CURRENT_TIMESTAMP`,
groupName, policyName, jsonbParam(docJSON))
if err != nil {
glog.Errorf("credential postgres: PutGroupInlinePolicy failed group=%s policy=%s: %v", groupName, policyName, err)
return fmt.Errorf("failed to upsert group inline policy: %w", err)
}
glog.V(0).Infof("credential postgres: PutGroupInlinePolicy group=%s policy=%s", groupName, policyName)
return nil
}
func (store *PostgresStore) GetGroupInlinePolicy(ctx context.Context, groupName, policyName string) (*policy_engine.PolicyDocument, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var docJSON []byte
err := store.db.QueryRowContext(ctx,
"SELECT document FROM group_inline_policies WHERE group_name = $1 AND policy_name = $2",
groupName, policyName).Scan(&docJSON)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
glog.Errorf("credential postgres: GetGroupInlinePolicy query failed group=%s policy=%s: %v", groupName, policyName, err)
return nil, fmt.Errorf("failed to query group inline policy: %w", err)
}
var doc policy_engine.PolicyDocument
if err := json.Unmarshal(docJSON, &doc); err != nil {
return nil, fmt.Errorf("failed to unmarshal group inline policy: %w", err)
}
return &doc, nil
}
func (store *PostgresStore) DeleteGroupInlinePolicy(ctx context.Context, groupName, policyName string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx,
"DELETE FROM group_inline_policies WHERE group_name = $1 AND policy_name = $2",
groupName, policyName)
if err != nil {
glog.Errorf("credential postgres: DeleteGroupInlinePolicy failed group=%s policy=%s: %v", groupName, policyName, err)
return fmt.Errorf("failed to delete group inline policy: %w", err)
}
rowsAffected, _ := result.RowsAffected()
glog.V(0).Infof("credential postgres: DeleteGroupInlinePolicy group=%s policy=%s deleted=%d", groupName, policyName, rowsAffected)
return nil
}
func (store *PostgresStore) ListGroupInlinePolicies(ctx context.Context, groupName string) ([]string, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
rows, err := store.db.QueryContext(ctx,
"SELECT policy_name FROM group_inline_policies WHERE group_name = $1 ORDER BY policy_name",
groupName)
if err != nil {
glog.Errorf("credential postgres: ListGroupInlinePolicies query failed group=%s: %v", groupName, err)
return nil, fmt.Errorf("failed to query group inline policies: %w", err)
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("failed to scan policy name: %w", err)
}
names = append(names, name)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed iterating group inline policy rows: %w", err)
}
return names, nil
}
func (store *PostgresStore) LoadGroupInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
rows, err := store.db.QueryContext(ctx,
"SELECT group_name, policy_name, document FROM group_inline_policies ORDER BY group_name, policy_name")
if err != nil {
glog.Errorf("credential postgres: LoadGroupInlinePolicies query failed: %v", err)
return nil, fmt.Errorf("failed to query group inline policies: %w", err)
}
defer rows.Close()
result := make(map[string]map[string]policy_engine.PolicyDocument)
count := 0
for rows.Next() {
var groupName, policyName string
var docJSON []byte
if err := rows.Scan(&groupName, &policyName, &docJSON); err != nil {
return nil, fmt.Errorf("failed to scan group inline policy row: %w", err)
}
var doc policy_engine.PolicyDocument
if err := json.Unmarshal(docJSON, &doc); err != nil {
glog.Warningf("credential postgres: LoadGroupInlinePolicies unmarshal failed group=%s policy=%s: %v", groupName, policyName, err)
continue
}
if result[groupName] == nil {
result[groupName] = make(map[string]policy_engine.PolicyDocument)
}
result[groupName][policyName] = doc
count++
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed iterating group inline policy rows: %w", err)
}
glog.V(0).Infof("credential postgres: LoadGroupInlinePolicies loaded %d policies for %d groups", count, len(result))
return result, nil
}

View File

@@ -0,0 +1,754 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func (store *PostgresStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3ApiConfiguration, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
config := &iam_pb.S3ApiConfiguration{}
rows, err := store.db.QueryContext(ctx, "SELECT username, email, account_data, actions, policy_names FROM users")
if err != nil {
glog.Errorf("credential postgres: LoadConfiguration query failed: %v", err)
return nil, fmt.Errorf("failed to query users: %w", err)
}
defer rows.Close()
for rows.Next() {
var username, email string
var accountDataJSON, actionsJSON, policyNamesJSON []byte
if err := rows.Scan(&username, &email, &accountDataJSON, &actionsJSON, &policyNamesJSON); err != nil {
glog.Errorf("credential postgres: LoadConfiguration scan failed: %v", err)
return nil, fmt.Errorf("failed to scan user row: %w", err)
}
identity := &iam_pb.Identity{
Name: username,
}
if len(accountDataJSON) > 0 {
if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil {
return nil, fmt.Errorf("failed to unmarshal account data for user %s: %v", username, err)
}
}
if len(actionsJSON) > 0 {
if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil {
return nil, fmt.Errorf("failed to unmarshal actions for user %s: %v", username, err)
}
}
if len(policyNamesJSON) > 0 {
if err := json.Unmarshal(policyNamesJSON, &identity.PolicyNames); err != nil {
return nil, fmt.Errorf("failed to unmarshal policy names for user %s: %v", username, err)
}
}
credRows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username)
if err != nil {
return nil, fmt.Errorf("failed to query credentials for user %s: %v", username, err)
}
for credRows.Next() {
var accessKey, secretKey string
if err := credRows.Scan(&accessKey, &secretKey); err != nil {
credRows.Close()
return nil, fmt.Errorf("failed to scan credential row for user %s: %v", username, err)
}
identity.Credentials = append(identity.Credentials, &iam_pb.Credential{
AccessKey: accessKey,
SecretKey: secretKey,
})
}
credErr := credRows.Err()
credRows.Close()
if credErr != nil {
return nil, fmt.Errorf("failed iterating credential rows for user %s: %w", username, credErr)
}
config.Identities = append(config.Identities, identity)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed iterating user rows: %w", err)
}
// groups are part of the snapshot: full-state reconciliation treats their
// absence as deletion
groupRows, err := store.db.QueryContext(ctx, "SELECT name, members, policy_names, disabled FROM groups")
if err != nil {
return nil, fmt.Errorf("failed to query groups: %w", err)
}
defer groupRows.Close()
for groupRows.Next() {
var name string
var membersJSON, policyNamesJSON []byte
var disabled bool
if err := groupRows.Scan(&name, &membersJSON, &policyNamesJSON, &disabled); err != nil {
return nil, fmt.Errorf("failed to scan group row: %w", err)
}
group := &iam_pb.Group{Name: name, Disabled: disabled}
if err := json.Unmarshal(membersJSON, &group.Members); err != nil {
return nil, fmt.Errorf("failed to unmarshal members for group %s: %w", name, err)
}
if err := json.Unmarshal(policyNamesJSON, &group.PolicyNames); err != nil {
return nil, fmt.Errorf("failed to unmarshal policy_names for group %s: %w", name, err)
}
config.Groups = append(config.Groups, group)
}
if err := groupRows.Err(); err != nil {
return nil, fmt.Errorf("failed iterating group rows: %w", err)
}
glog.V(0).Infof("credential postgres: LoadConfiguration loaded %d identities, %d groups", len(config.Identities), len(config.Groups))
return config, nil
}
func (store *PostgresStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Track which usernames are in the incoming config for pruning
configUsernames := make(map[string]bool, len(config.Identities))
for _, identity := range config.Identities {
configUsernames[identity.Name] = true
}
// Upsert each user's row.
for _, identity := range config.Identities {
var accountDataJSON []byte
if identity.Account != nil {
accountDataJSON, err = json.Marshal(identity.Account)
if err != nil {
return fmt.Errorf("failed to marshal account data for user %s: %v", identity.Name, err)
}
}
var actionsJSON []byte
if identity.Actions != nil {
actionsJSON, err = json.Marshal(identity.Actions)
if err != nil {
return fmt.Errorf("failed to marshal actions for user %s: %v", identity.Name, err)
}
}
var policyNamesJSON []byte
if identity.PolicyNames != nil {
policyNamesJSON, err = json.Marshal(identity.PolicyNames)
if err != nil {
return fmt.Errorf("failed to marshal policy names for user %s: %v", identity.Name, err)
}
}
// Upsert user — preserves the row (and its CASCADE dependents) if it already exists
_, err = tx.ExecContext(ctx,
`INSERT INTO users (username, email, account_data, actions, policy_names)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
account_data = EXCLUDED.account_data,
actions = EXCLUDED.actions,
policy_names = EXCLUDED.policy_names,
updated_at = CURRENT_TIMESTAMP`,
identity.Name, "", jsonbParam(accountDataJSON), jsonbParam(actionsJSON), jsonbParam(policyNamesJSON))
if err != nil {
return fmt.Errorf("failed to upsert user %s: %v", identity.Name, err)
}
}
// Prune users no longer in config BEFORE replacing credentials. The
// IAM rename path (s3api UpdateUser) renames an identity in place and
// keeps its access keys: the renamed user shows up in the incoming
// config, the old name shows up in the pruned set. If we inserted the
// new credentials first, the renamed user's access keys would still be
// owned by the old row in this transaction and the INSERT would
// violate the global UNIQUE constraint on credentials.access_key.
// CASCADE on the old row releases those keys before the insert.
rows, err := tx.QueryContext(ctx, "SELECT username FROM users")
if err != nil {
return fmt.Errorf("failed to list existing users for pruning: %w", err)
}
var toDelete []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
rows.Close()
return fmt.Errorf("failed to scan username for pruning: %w", err)
}
if !configUsernames[username] {
toDelete = append(toDelete, username)
}
}
scanErr := rows.Err()
rows.Close()
if scanErr != nil {
return fmt.Errorf("failed iterating user rows for pruning: %w", scanErr)
}
if len(toDelete) > 0 {
var inlineCount int
if err := tx.QueryRowContext(ctx,
"SELECT COUNT(*) FROM user_inline_policies WHERE username = ANY($1)",
toDelete).Scan(&inlineCount); err != nil {
glog.Warningf("credential postgres: SaveConfiguration failed to count inline policies for prune candidates: %v", err)
} else if inlineCount > 0 {
glog.Warningf("credential postgres: SaveConfiguration pruning %d users will CASCADE-remove %d inline policies; if this was a rename, re-create them under the new name", len(toDelete), inlineCount)
}
if _, err := tx.ExecContext(ctx, "DELETE FROM users WHERE username = ANY($1)", toDelete); err != nil {
return fmt.Errorf("failed to prune users: %w", err)
}
}
// Two-pass credential replace: first clear every user we are about to
// rewrite in a single round-trip, then insert. Doing the per-user delete
// + insert in a single pass would violate the global UNIQUE constraint
// on credentials.access_key when an access key gets reassigned from one
// user to another within the same SaveConfiguration call.
usernames := make([]string, 0, len(configUsernames))
for name := range configUsernames {
usernames = append(usernames, name)
}
if _, err := tx.ExecContext(ctx, "DELETE FROM credentials WHERE username = ANY($1)", usernames); err != nil {
return fmt.Errorf("failed to clear credentials for incoming users: %w", err)
}
for _, identity := range config.Identities {
for _, cred := range identity.Credentials {
if _, err := tx.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
identity.Name, cred.AccessKey, cred.SecretKey); err != nil {
return fmt.Errorf("failed to insert credential for user %s: %v", identity.Name, err)
}
}
}
if err := tx.Commit(); err != nil {
glog.Errorf("credential postgres: SaveConfiguration commit failed: %v", err)
return fmt.Errorf("failed to commit SaveConfiguration: %w", err)
}
if len(toDelete) > 0 {
glog.Warningf("credential postgres: SaveConfiguration saved %d identities, pruned %d", len(config.Identities), len(toDelete))
} else {
glog.V(0).Infof("credential postgres: SaveConfiguration saved %d identities", len(config.Identities))
}
return nil
}
func (store *PostgresStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
var count int
err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", identity.Name).Scan(&count)
if err != nil {
glog.Errorf("credential postgres: CreateUser check failed user=%s: %v", identity.Name, err)
return fmt.Errorf("failed to check user existence: %w", err)
}
if count > 0 {
glog.V(1).Infof("credential postgres: CreateUser user=%s already exists", identity.Name)
return credential.ErrUserAlreadyExists
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
var accountDataJSON []byte
if identity.Account != nil {
accountDataJSON, err = json.Marshal(identity.Account)
if err != nil {
return fmt.Errorf("failed to marshal account data: %w", err)
}
}
var actionsJSON []byte
if identity.Actions != nil {
actionsJSON, err = json.Marshal(identity.Actions)
if err != nil {
return fmt.Errorf("failed to marshal actions: %w", err)
}
}
var policyNamesJSON []byte
if identity.PolicyNames != nil {
policyNamesJSON, err = json.Marshal(identity.PolicyNames)
if err != nil {
return fmt.Errorf("failed to marshal policy names: %w", err)
}
}
_, err = tx.ExecContext(ctx,
"INSERT INTO users (username, email, account_data, actions, policy_names) VALUES ($1, $2, $3, $4, $5)",
identity.Name, "", jsonbParam(accountDataJSON), jsonbParam(actionsJSON), jsonbParam(policyNamesJSON))
if err != nil {
glog.Errorf("credential postgres: CreateUser insert failed user=%s: %v", identity.Name, err)
return fmt.Errorf("failed to insert user: %w", err)
}
for _, cred := range identity.Credentials {
_, err = tx.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
identity.Name, cred.AccessKey, cred.SecretKey)
if err != nil {
glog.Errorf("credential postgres: CreateUser insert credential failed user=%s accessKey=%s: %v", identity.Name, cred.AccessKey, err)
return fmt.Errorf("failed to insert credential: %w", err)
}
}
if err := tx.Commit(); err != nil {
glog.Errorf("credential postgres: CreateUser commit failed user=%s: %v", identity.Name, err)
return fmt.Errorf("failed to commit create user %s: %w", identity.Name, err)
}
glog.V(0).Infof("credential postgres: CreateUser user=%s credentials=%d actions=%d", identity.Name, len(identity.Credentials), len(identity.Actions))
return nil
}
func (store *PostgresStore) GetUser(ctx context.Context, username string) (*iam_pb.Identity, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var email string
var accountDataJSON, actionsJSON, policyNamesJSON []byte
err := store.db.QueryRowContext(ctx,
"SELECT email, account_data, actions, policy_names FROM users WHERE username = $1",
username).Scan(&email, &accountDataJSON, &actionsJSON, &policyNamesJSON)
if err != nil {
if err == sql.ErrNoRows {
glog.V(2).Infof("credential postgres: GetUser user=%s not found", username)
return nil, credential.ErrUserNotFound
}
glog.Errorf("credential postgres: GetUser query failed user=%s: %v", username, err)
return nil, fmt.Errorf("failed to query user: %w", err)
}
identity := &iam_pb.Identity{
Name: username,
}
if len(accountDataJSON) > 0 {
if err := json.Unmarshal(accountDataJSON, &identity.Account); err != nil {
return nil, fmt.Errorf("failed to unmarshal account data: %w", err)
}
}
if len(actionsJSON) > 0 {
if err := json.Unmarshal(actionsJSON, &identity.Actions); err != nil {
return nil, fmt.Errorf("failed to unmarshal actions: %w", err)
}
}
if len(policyNamesJSON) > 0 {
if err := json.Unmarshal(policyNamesJSON, &identity.PolicyNames); err != nil {
return nil, fmt.Errorf("failed to unmarshal policy names: %w", err)
}
}
rows, err := store.db.QueryContext(ctx, "SELECT access_key, secret_key FROM credentials WHERE username = $1", username)
if err != nil {
return nil, fmt.Errorf("failed to query credentials: %w", err)
}
defer rows.Close()
for rows.Next() {
var accessKey, secretKey string
if err := rows.Scan(&accessKey, &secretKey); err != nil {
return nil, fmt.Errorf("failed to scan credential: %w", err)
}
identity.Credentials = append(identity.Credentials, &iam_pb.Credential{
AccessKey: accessKey,
SecretKey: secretKey,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed iterating credential rows: %w", err)
}
glog.V(2).Infof("credential postgres: GetUser user=%s credentials=%d actions=%d", username, len(identity.Credentials), len(identity.Actions))
return identity, nil
}
func (store *PostgresStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
var count int
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %w", err)
}
if count == 0 {
return credential.ErrUserNotFound
}
var accountDataJSON []byte
if identity.Account != nil {
accountDataJSON, err = json.Marshal(identity.Account)
if err != nil {
return fmt.Errorf("failed to marshal account data: %w", err)
}
}
var actionsJSON []byte
if identity.Actions != nil {
actionsJSON, err = json.Marshal(identity.Actions)
if err != nil {
return fmt.Errorf("failed to marshal actions: %w", err)
}
}
var policyNamesJSON []byte
if identity.PolicyNames != nil {
policyNamesJSON, err = json.Marshal(identity.PolicyNames)
if err != nil {
return fmt.Errorf("failed to marshal policy names: %w", err)
}
}
_, err = tx.ExecContext(ctx,
"UPDATE users SET email = $2, account_data = $3, actions = $4, policy_names = $5, updated_at = CURRENT_TIMESTAMP WHERE username = $1",
username, "", jsonbParam(accountDataJSON), jsonbParam(actionsJSON), jsonbParam(policyNamesJSON))
if err != nil {
glog.Errorf("credential postgres: UpdateUser failed user=%s: %v", username, err)
return fmt.Errorf("failed to update user: %w", err)
}
_, err = tx.ExecContext(ctx, "DELETE FROM credentials WHERE username = $1", username)
if err != nil {
return fmt.Errorf("failed to delete existing credentials: %w", err)
}
for _, cred := range identity.Credentials {
_, err = tx.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
username, cred.AccessKey, cred.SecretKey)
if err != nil {
return fmt.Errorf("failed to insert credential: %w", err)
}
}
if err := tx.Commit(); err != nil {
glog.Errorf("credential postgres: UpdateUser commit failed user=%s: %v", username, err)
return fmt.Errorf("failed to commit update user %s: %w", username, err)
}
glog.V(0).Infof("credential postgres: UpdateUser user=%s credentials=%d", username, len(identity.Credentials))
return nil
}
func (store *PostgresStore) DeleteUser(ctx context.Context, username string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx, "DELETE FROM users WHERE username = $1", username)
if err != nil {
glog.Errorf("credential postgres: DeleteUser failed user=%s: %v", username, err)
return fmt.Errorf("failed to delete user: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
glog.V(1).Infof("credential postgres: DeleteUser user=%s not found", username)
return credential.ErrUserNotFound
}
glog.V(0).Infof("credential postgres: DeleteUser user=%s", username)
return nil
}
// RenameUser atomically renames oldName to newName, re-pointing every
// FK-backed dependent row in a single transaction. The schema's foreign
// keys (credentials.username, user_inline_policies.username) reference
// users.username with ON DELETE CASCADE and the default ON UPDATE
// NO ACTION, which means a plain UPDATE users SET username = newName
// would fail because the children still reference the old name. Instead:
// 1. Insert the new users row by copying every non-key column from old.
// 2. Re-point the dependent tables (credentials, user_inline_policies)
// to the new username — both rows now exist so the FK is satisfied.
// 3. Delete the old users row, which has no remaining dependents.
//
// Implements credential.UserRenamer.
func (store *PostgresStore) RenameUser(ctx context.Context, oldName, newName string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
if oldName == newName {
return nil
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Refuse if oldName is missing or newName already exists, so the
// caller surfaces a recognisable error instead of a constraint
// violation midway through.
var oldExists, newExists bool
if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", oldName).Scan(&oldExists); err != nil {
return fmt.Errorf("failed to check old user %s: %w", oldName, err)
}
if !oldExists {
return credential.ErrUserNotFound
}
if err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", newName).Scan(&newExists); err != nil {
return fmt.Errorf("failed to check new user %s: %w", newName, err)
}
if newExists {
return credential.ErrUserAlreadyExists
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO users (username, email, account_data, actions, policy_names, created_at, updated_at)
SELECT $1, email, account_data, actions, policy_names, created_at, CURRENT_TIMESTAMP
FROM users WHERE username = $2`,
newName, oldName); err != nil {
return fmt.Errorf("failed to insert renamed user %s: %w", newName, err)
}
if _, err := tx.ExecContext(ctx,
"UPDATE credentials SET username = $1 WHERE username = $2", newName, oldName); err != nil {
return fmt.Errorf("failed to re-point credentials to %s: %w", newName, err)
}
if _, err := tx.ExecContext(ctx,
"UPDATE user_inline_policies SET username = $1 WHERE username = $2", newName, oldName); err != nil {
return fmt.Errorf("failed to re-point inline policies to %s: %w", newName, err)
}
if _, err := tx.ExecContext(ctx,
"DELETE FROM users WHERE username = $1", oldName); err != nil {
return fmt.Errorf("failed to drop old user %s: %w", oldName, err)
}
if err := tx.Commit(); err != nil {
glog.Errorf("credential postgres: RenameUser commit failed %s -> %s: %v", oldName, newName, err)
return fmt.Errorf("failed to commit rename %s -> %s: %w", oldName, newName, err)
}
glog.V(0).Infof("credential postgres: RenameUser %s -> %s", oldName, newName)
return nil
}
func (store *PostgresStore) ListUsers(ctx context.Context) ([]string, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
rows, err := store.db.QueryContext(ctx, "SELECT username FROM users ORDER BY username")
if err != nil {
glog.Errorf("credential postgres: ListUsers query failed: %v", err)
return nil, fmt.Errorf("failed to query users: %w", err)
}
defer rows.Close()
var usernames []string
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, fmt.Errorf("failed to scan username: %w", err)
}
usernames = append(usernames, username)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed iterating user rows: %w", err)
}
glog.V(1).Infof("credential postgres: ListUsers count=%d", len(usernames))
return usernames, nil
}
func (store *PostgresStore) GetUserByAccessKey(ctx context.Context, accessKey string) (*iam_pb.Identity, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var username string
err := store.db.QueryRowContext(ctx, "SELECT username FROM credentials WHERE access_key = $1", accessKey).Scan(&username)
if err != nil {
if err == sql.ErrNoRows {
glog.V(2).Infof("credential postgres: GetUserByAccessKey accessKey=%s not found", accessKey)
return nil, credential.ErrAccessKeyNotFound
}
glog.Errorf("credential postgres: GetUserByAccessKey query failed accessKey=%s: %v", accessKey, err)
return nil, fmt.Errorf("failed to query access key: %w", err)
}
glog.V(2).Infof("credential postgres: GetUserByAccessKey accessKey=%s resolved to user=%s", accessKey, username)
return store.GetUser(ctx, username)
}
func (store *PostgresStore) CreateAccessKey(ctx context.Context, username string, cred *iam_pb.Credential) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
var count int
err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %w", err)
}
if count == 0 {
return credential.ErrUserNotFound
}
_, err = store.db.ExecContext(ctx,
"INSERT INTO credentials (username, access_key, secret_key) VALUES ($1, $2, $3)",
username, cred.AccessKey, cred.SecretKey)
if err != nil {
glog.Errorf("credential postgres: CreateAccessKey failed user=%s accessKey=%s: %v", username, cred.AccessKey, err)
return fmt.Errorf("failed to insert credential: %w", err)
}
glog.V(0).Infof("credential postgres: CreateAccessKey user=%s accessKey=%s", username, cred.AccessKey)
return nil
}
func (store *PostgresStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx,
"DELETE FROM credentials WHERE username = $1 AND access_key = $2",
username, accessKey)
if err != nil {
glog.Errorf("credential postgres: DeleteAccessKey failed user=%s accessKey=%s: %v", username, accessKey, err)
return fmt.Errorf("failed to delete access key: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
var count int
err = store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check user existence: %w", err)
}
if count == 0 {
return credential.ErrUserNotFound
}
return credential.ErrAccessKeyNotFound
}
glog.V(0).Infof("credential postgres: DeleteAccessKey user=%s accessKey=%s", username, accessKey)
return nil
}
func (store *PostgresStore) AttachUserPolicy(ctx context.Context, username string, policyName string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
identity, err := store.GetUser(ctx, username)
if err != nil {
return err
}
policy, err := store.GetPolicy(ctx, policyName)
if err != nil {
return err
}
if policy == nil {
return credential.ErrPolicyNotFound
}
for _, p := range identity.PolicyNames {
if p == policyName {
return credential.ErrPolicyAlreadyAttached
}
}
identity.PolicyNames = append(identity.PolicyNames, policyName)
if err := store.UpdateUser(ctx, username, identity); err != nil {
return err
}
glog.V(1).Infof("credential postgres: AttachUserPolicy user=%s policy=%s", username, policyName)
return nil
}
func (store *PostgresStore) DetachUserPolicy(ctx context.Context, username string, policyName string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
identity, err := store.GetUser(ctx, username)
if err != nil {
return err
}
found := false
var newPolicyNames []string
for _, p := range identity.PolicyNames {
if p == policyName {
found = true
} else {
newPolicyNames = append(newPolicyNames, p)
}
}
if !found {
return credential.ErrPolicyNotAttached
}
identity.PolicyNames = newPolicyNames
if err := store.UpdateUser(ctx, username, identity); err != nil {
return err
}
glog.V(1).Infof("credential postgres: DetachUserPolicy user=%s policy=%s", username, policyName)
return nil
}
func (store *PostgresStore) ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
identity, err := store.GetUser(ctx, username)
if err != nil {
return nil, err
}
return identity.PolicyNames, nil
}

View File

@@ -0,0 +1,154 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
)
func (store *PostgresStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
docJSON, err := json.Marshal(document)
if err != nil {
glog.Errorf("credential postgres: PutUserInlinePolicy marshal failed user=%s policy=%s: %v", userName, policyName, err)
return fmt.Errorf("failed to marshal policy document: %w", err)
}
_, err = store.db.ExecContext(ctx,
`INSERT INTO user_inline_policies (username, policy_name, document)
VALUES ($1, $2, $3)
ON CONFLICT (username, policy_name)
DO UPDATE SET document = $3, updated_at = CURRENT_TIMESTAMP`,
userName, policyName, jsonbParam(docJSON))
if err != nil {
glog.Errorf("credential postgres: PutUserInlinePolicy failed user=%s policy=%s: %v", userName, policyName, err)
return fmt.Errorf("failed to upsert inline policy: %w", err)
}
glog.V(0).Infof("credential postgres: PutUserInlinePolicy user=%s policy=%s", userName, policyName)
return nil
}
func (store *PostgresStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var docJSON []byte
err := store.db.QueryRowContext(ctx,
"SELECT document FROM user_inline_policies WHERE username = $1 AND policy_name = $2",
userName, policyName).Scan(&docJSON)
if err != nil {
if err == sql.ErrNoRows {
glog.V(2).Infof("credential postgres: GetUserInlinePolicy user=%s policy=%s not found", userName, policyName)
return nil, nil
}
glog.Errorf("credential postgres: GetUserInlinePolicy query failed user=%s policy=%s: %v", userName, policyName, err)
return nil, fmt.Errorf("failed to query inline policy: %w", err)
}
var doc policy_engine.PolicyDocument
if err := json.Unmarshal(docJSON, &doc); err != nil {
return nil, fmt.Errorf("failed to unmarshal inline policy: %w", err)
}
glog.V(2).Infof("credential postgres: GetUserInlinePolicy user=%s policy=%s found", userName, policyName)
return &doc, nil
}
func (store *PostgresStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx,
"DELETE FROM user_inline_policies WHERE username = $1 AND policy_name = $2",
userName, policyName)
if err != nil {
glog.Errorf("credential postgres: DeleteUserInlinePolicy failed user=%s policy=%s: %v", userName, policyName, err)
return fmt.Errorf("failed to delete inline policy: %w", err)
}
rowsAffected, _ := result.RowsAffected()
glog.V(0).Infof("credential postgres: DeleteUserInlinePolicy user=%s policy=%s deleted=%d", userName, policyName, rowsAffected)
return nil
}
func (store *PostgresStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
rows, err := store.db.QueryContext(ctx,
"SELECT policy_name FROM user_inline_policies WHERE username = $1 ORDER BY policy_name",
userName)
if err != nil {
glog.Errorf("credential postgres: ListUserInlinePolicies query failed user=%s: %v", userName, err)
return nil, fmt.Errorf("failed to query inline policies: %w", err)
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("failed to scan policy name: %w", err)
}
names = append(names, name)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed iterating inline policy rows: %w", err)
}
glog.V(1).Infof("credential postgres: ListUserInlinePolicies user=%s count=%d", userName, len(names))
return names, nil
}
func (store *PostgresStore) LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
rows, err := store.db.QueryContext(ctx,
"SELECT username, policy_name, document FROM user_inline_policies ORDER BY username, policy_name")
if err != nil {
glog.Errorf("credential postgres: LoadInlinePolicies query failed: %v", err)
return nil, fmt.Errorf("failed to query inline policies: %w", err)
}
defer rows.Close()
result := make(map[string]map[string]policy_engine.PolicyDocument)
count := 0
for rows.Next() {
var username, policyName string
var docJSON []byte
if err := rows.Scan(&username, &policyName, &docJSON); err != nil {
return nil, fmt.Errorf("failed to scan inline policy row: %w", err)
}
var doc policy_engine.PolicyDocument
if err := json.Unmarshal(docJSON, &doc); err != nil {
glog.Warningf("credential postgres: LoadInlinePolicies unmarshal failed user=%s policy=%s: %v", username, policyName, err)
continue
}
if result[username] == nil {
result[username] = make(map[string]policy_engine.PolicyDocument)
}
result[username][policyName] = doc
count++
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed iterating inline policy rows: %w", err)
}
glog.V(0).Infof("credential postgres: LoadInlinePolicies loaded %d policies for %d users", count, len(result))
return result, nil
}

View File

@@ -0,0 +1,159 @@
package postgres
import (
"context"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
)
// GetPolicies retrieves all IAM policies from PostgreSQL
func (store *PostgresStore) GetPolicies(ctx context.Context) (map[string]policy_engine.PolicyDocument, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
policies := make(map[string]policy_engine.PolicyDocument)
rows, err := store.db.QueryContext(ctx, "SELECT name, document FROM policies")
if err != nil {
return nil, fmt.Errorf("failed to query policies: %w", err)
}
defer rows.Close()
for rows.Next() {
var name string
var documentJSON []byte
if err := rows.Scan(&name, &documentJSON); err != nil {
return nil, fmt.Errorf("failed to scan policy row: %w", err)
}
var document policy_engine.PolicyDocument
if err := json.Unmarshal(documentJSON, &document); err != nil {
return nil, fmt.Errorf("failed to unmarshal policy document for %s: %v", name, err)
}
policies[name] = document
}
return policies, nil
}
// ListPolicyNames returns all managed policy names from PostgreSQL.
func (store *PostgresStore) ListPolicyNames(ctx context.Context) ([]string, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var names []string
rows, err := store.db.QueryContext(ctx, "SELECT name FROM policies")
if err != nil {
return nil, fmt.Errorf("failed to query policy names: %w", err)
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("failed to scan policy name: %w", err)
}
names = append(names, name)
}
return names, nil
}
// CreatePolicy creates a new IAM policy in PostgreSQL
func (store *PostgresStore) CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
documentJSON, err := json.Marshal(document)
if err != nil {
return fmt.Errorf("failed to marshal policy document: %w", err)
}
_, err = store.db.ExecContext(ctx,
"INSERT INTO policies (name, document) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET document = $2, updated_at = CURRENT_TIMESTAMP",
name, jsonbParam(documentJSON))
if err != nil {
return fmt.Errorf("failed to insert policy: %w", err)
}
return nil
}
// PutPolicy creates or updates an IAM policy in PostgreSQL
func (store *PostgresStore) PutPolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
return store.CreatePolicy(ctx, name, document)
}
// UpdatePolicy updates an existing IAM policy in PostgreSQL
func (store *PostgresStore) UpdatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
documentJSON, err := json.Marshal(document)
if err != nil {
return fmt.Errorf("failed to marshal policy document: %w", err)
}
result, err := store.db.ExecContext(ctx,
"UPDATE policies SET document = $2, updated_at = CURRENT_TIMESTAMP WHERE name = $1",
name, jsonbParam(documentJSON))
if err != nil {
return fmt.Errorf("failed to update policy: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("policy %s not found", name)
}
return nil
}
// DeletePolicy deletes an IAM policy from PostgreSQL
func (store *PostgresStore) DeletePolicy(ctx context.Context, name string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx, "DELETE FROM policies WHERE name = $1", name)
if err != nil {
return fmt.Errorf("failed to delete policy: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("policy %s not found", name)
}
return nil
}
// GetPolicy retrieves a specific IAM policy by name from PostgreSQL
func (store *PostgresStore) GetPolicy(ctx context.Context, name string) (*policy_engine.PolicyDocument, error) {
policies, err := store.GetPolicies(ctx)
if err != nil {
return nil, err
}
if policy, exists := policies[name]; exists {
return &policy, nil
}
return nil, nil // Policy not found
}

View File

@@ -0,0 +1,173 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
func (store *PostgresStore) CreateServiceAccount(ctx context.Context, sa *iam_pb.ServiceAccount) error {
if sa == nil {
return fmt.Errorf("service account is nil")
}
if sa.Id == "" {
return fmt.Errorf("service account ID is required")
}
if !store.configured {
return fmt.Errorf("store not configured")
}
data, err := json.Marshal(sa)
if err != nil {
return fmt.Errorf("failed to marshal service account: %w", err)
}
accessKey := ""
if sa.Credential != nil {
accessKey = sa.Credential.AccessKey
}
_, err = store.db.ExecContext(ctx,
"INSERT INTO service_accounts (id, access_key, content) VALUES ($1, $2, $3)",
sa.Id, accessKey, jsonbParam(data))
if err != nil {
return fmt.Errorf("failed to insert service account: %w", err)
}
return nil
}
func (store *PostgresStore) UpdateServiceAccount(ctx context.Context, id string, sa *iam_pb.ServiceAccount) error {
if sa == nil {
return fmt.Errorf("service account is nil")
}
if id == "" {
return fmt.Errorf("service account ID is required")
}
if sa.Id != id {
return fmt.Errorf("service account ID mismatch")
}
data, err := json.Marshal(sa)
if err != nil {
return fmt.Errorf("failed to marshal service account: %w", err)
}
accessKey := ""
if sa.Credential != nil {
accessKey = sa.Credential.AccessKey
}
result, err := store.db.ExecContext(ctx,
"UPDATE service_accounts SET access_key = $2, content = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $1",
id, accessKey, jsonbParam(data))
if err != nil {
return fmt.Errorf("failed to update service account: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return credential.ErrServiceAccountNotFound
}
return nil
}
func (store *PostgresStore) DeleteServiceAccount(ctx context.Context, id string) error {
if !store.configured {
return fmt.Errorf("store not configured")
}
result, err := store.db.ExecContext(ctx, "DELETE FROM service_accounts WHERE id = $1", id)
if err != nil {
return fmt.Errorf("failed to delete service account: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return credential.ErrServiceAccountNotFound
}
return nil
}
func (store *PostgresStore) GetServiceAccount(ctx context.Context, id string) (*iam_pb.ServiceAccount, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var content []byte
err := store.db.QueryRowContext(ctx, "SELECT content FROM service_accounts WHERE id = $1", id).Scan(&content)
if err != nil {
if err == sql.ErrNoRows {
return nil, credential.ErrServiceAccountNotFound
}
return nil, fmt.Errorf("failed to get service account: %w", err)
}
sa := &iam_pb.ServiceAccount{}
if err := json.Unmarshal(content, sa); err != nil {
return nil, fmt.Errorf("failed to unmarshal service account: %w", err)
}
return sa, nil
}
func (store *PostgresStore) ListServiceAccounts(ctx context.Context) ([]*iam_pb.ServiceAccount, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
rows, err := store.db.QueryContext(ctx, "SELECT content FROM service_accounts")
if err != nil {
return nil, fmt.Errorf("failed to list service accounts: %w", err)
}
defer rows.Close()
var accounts []*iam_pb.ServiceAccount
for rows.Next() {
var content []byte
if err := rows.Scan(&content); err != nil {
return nil, fmt.Errorf("failed to scan service account: %w", err)
}
sa := &iam_pb.ServiceAccount{}
if err := json.Unmarshal(content, sa); err != nil {
return nil, fmt.Errorf("failed to unmarshal service account: %w", err)
}
accounts = append(accounts, sa)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating service accounts: %w", err)
}
return accounts, nil
}
func (store *PostgresStore) GetServiceAccountByAccessKey(ctx context.Context, accessKey string) (*iam_pb.ServiceAccount, error) {
if !store.configured {
return nil, fmt.Errorf("store not configured")
}
var content []byte
err := store.db.QueryRowContext(ctx, "SELECT content FROM service_accounts WHERE access_key = $1", accessKey).Scan(&content)
if err != nil {
if err == sql.ErrNoRows {
return nil, credential.ErrAccessKeyNotFound
}
return nil, fmt.Errorf("failed to query service account by access key: %w", err)
}
sa := &iam_pb.ServiceAccount{}
if err := json.Unmarshal(content, sa); err != nil {
return nil, fmt.Errorf("failed to unmarshal service account: %w", err)
}
return sa, nil
}

View File

@@ -0,0 +1,263 @@
package postgres
import (
"database/sql"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/pgxutil"
)
const (
defaultMaxOpenConns = 25
defaultMaxIdleConns = 5
defaultConnMaxLifetimeSecs = 300
)
// jsonbParam adapts JSON bytes for an ExecContext call against a JSONB
// column. Returns nil (so the driver writes SQL NULL) when b is nil or
// empty; otherwise returns string(b) so pgx drives it as JSONB text — []byte
// would be encoded as bytea under simple_protocol (pgbouncer mode) and
// rejected by Postgres with "invalid input syntax for type json".
func jsonbParam(b []byte) interface{} {
if len(b) == 0 {
return nil
}
return string(b)
}
func init() {
credential.Stores = append(credential.Stores, &PostgresStore{})
}
// PostgresStore implements CredentialStore using PostgreSQL
type PostgresStore struct {
db *sql.DB
configured bool
}
func (store *PostgresStore) GetName() credential.CredentialStoreTypeName {
return credential.StoreTypePostgres
}
func (store *PostgresStore) Initialize(configuration util.Configuration, prefix string) error {
if store.configured {
return nil
}
hostname := configuration.GetString(prefix + "hostname")
port := configuration.GetInt(prefix + "port")
username := configuration.GetString(prefix + "username")
password := configuration.GetString(prefix + "password")
database := configuration.GetString(prefix + "database")
schema := configuration.GetString(prefix + "schema")
sslmode := configuration.GetString(prefix + "sslmode")
sslcert := configuration.GetString(prefix + "sslcert")
sslkey := configuration.GetString(prefix + "sslkey")
sslrootcert := configuration.GetString(prefix + "sslrootcert")
sslcrl := configuration.GetString(prefix + "sslcrl")
pgbouncerCompatible := configuration.GetBool(prefix + "pgbouncer_compatible")
maxIdle := configuration.GetInt(prefix + "connection_max_idle")
maxOpen := configuration.GetInt(prefix + "connection_max_open")
maxLifetimeSeconds := configuration.GetInt(prefix + "connection_max_lifetime_seconds")
if hostname == "" {
hostname = "localhost"
}
if port == 0 {
port = 5432
}
if sslmode == "" {
sslmode = "disable"
}
if maxOpen == 0 {
maxOpen = defaultMaxOpenConns
}
if maxIdle == 0 {
maxIdle = defaultMaxIdleConns
}
if maxLifetimeSeconds == 0 {
maxLifetimeSeconds = defaultConnMaxLifetimeSecs
}
glog.V(0).Infof("credential postgres: initializing store host=%s port=%d user=%s db=%s sslmode=%s pgbouncer=%v",
hostname, port, username, database, sslmode, pgbouncerCompatible)
dsn, adaptedDSN := pgxutil.BuildDSN(pgxutil.DSNOptions{
Hostname: hostname,
Port: port,
User: username,
Password: password,
Database: database,
Schema: schema,
SSLMode: sslmode,
SSLCert: sslcert,
SSLKey: sslkey,
SSLRootCert: sslrootcert,
SSLCRL: sslcrl,
PgBouncerCompatible: pgbouncerCompatible,
})
db, err := pgxutil.OpenDB(dsn, adaptedDSN, pgbouncerCompatible, maxIdle, maxOpen, maxLifetimeSeconds)
if err != nil {
glog.Errorf("credential postgres: failed to open database: %v", err)
return fmt.Errorf("failed to open database: %w", err)
}
glog.V(0).Infof("credential postgres: connection established")
store.db = db
if err := store.createTables(); err != nil {
db.Close()
store.db = nil
glog.Errorf("credential postgres: failed to create tables: %v", err)
return fmt.Errorf("failed to create tables: %w", err)
}
glog.V(0).Infof("credential postgres: tables verified, store ready")
store.configured = true
return nil
}
func (store *PostgresStore) createTables() error {
usersTable := `
CREATE TABLE IF NOT EXISTS users (
username VARCHAR(255) PRIMARY KEY,
email VARCHAR(255),
account_data JSONB,
actions JSONB,
policy_names JSONB DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
`
addPolicyNamesColumn := `
ALTER TABLE users ADD COLUMN IF NOT EXISTS policy_names JSONB DEFAULT '[]';
`
credentialsTable := `
CREATE TABLE IF NOT EXISTS credentials (
id SERIAL PRIMARY KEY,
username VARCHAR(255) REFERENCES users(username) ON DELETE CASCADE,
access_key VARCHAR(255) UNIQUE NOT NULL,
secret_key VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_credentials_username ON credentials(username);
CREATE INDEX IF NOT EXISTS idx_credentials_access_key ON credentials(access_key);
`
policiesTable := `
CREATE TABLE IF NOT EXISTS policies (
name VARCHAR(255) PRIMARY KEY,
document JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_policies_name ON policies(name);
`
serviceAccountsTable := `
CREATE TABLE IF NOT EXISTS service_accounts (
id VARCHAR(255) PRIMARY KEY,
access_key VARCHAR(255) UNIQUE,
content JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`
inlinePoliciesTable := `
CREATE TABLE IF NOT EXISTS user_inline_policies (
username VARCHAR(255) REFERENCES users(username) ON DELETE CASCADE,
policy_name VARCHAR(255) NOT NULL,
document JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (username, policy_name)
);
CREATE INDEX IF NOT EXISTS idx_user_inline_policies_username ON user_inline_policies(username);
`
groupsTable := `
CREATE TABLE IF NOT EXISTS groups (
name VARCHAR(255) PRIMARY KEY,
members JSONB DEFAULT '[]',
policy_names JSONB DEFAULT '[]',
disabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`
groupInlinePoliciesTable := `
CREATE TABLE IF NOT EXISTS group_inline_policies (
group_name VARCHAR(255) REFERENCES groups(name) ON DELETE CASCADE,
policy_name VARCHAR(255) NOT NULL,
document JSONB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (group_name, policy_name)
);
CREATE INDEX IF NOT EXISTS idx_group_inline_policies_group_name ON group_inline_policies(group_name);
`
if _, err := store.db.Exec(usersTable); err != nil {
return fmt.Errorf("failed to create users table: %w", err)
}
if _, err := store.db.Exec(addPolicyNamesColumn); err != nil {
return fmt.Errorf("failed to add policy_names column: %w", err)
}
if _, err := store.db.Exec(credentialsTable); err != nil {
return fmt.Errorf("failed to create credentials table: %w", err)
}
if _, err := store.db.Exec(policiesTable); err != nil {
return fmt.Errorf("failed to create policies table: %w", err)
}
if _, err := store.db.Exec(serviceAccountsTable); err != nil {
return fmt.Errorf("failed to create service_accounts table: %w", err)
}
if _, err := store.db.Exec(inlinePoliciesTable); err != nil {
return fmt.Errorf("failed to create user_inline_policies table: %w", err)
}
if _, err := store.db.Exec(groupsTable); err != nil {
return fmt.Errorf("failed to create groups table: %w", err)
}
if _, err := store.db.Exec(groupInlinePoliciesTable); err != nil {
return fmt.Errorf("failed to create group_inline_policies table: %w", err)
}
groupsDisabledIndex := `CREATE INDEX IF NOT EXISTS idx_groups_disabled ON groups (disabled);`
if _, err := store.db.Exec(groupsDisabledIndex); err != nil {
return fmt.Errorf("failed to create groups disabled index: %w", err)
}
groupsMembersIndex := `CREATE INDEX IF NOT EXISTS idx_groups_members_gin ON groups USING GIN (members);`
if _, err := store.db.Exec(groupsMembersIndex); err != nil {
return fmt.Errorf("failed to create groups members index: %w", err)
}
return nil
}
func (store *PostgresStore) Shutdown() {
if store.db != nil {
glog.V(0).Infof("credential postgres: shutting down")
store.db.Close()
store.db = nil
}
store.configured = false
}

View File

@@ -0,0 +1,602 @@
package credential
import (
"context"
"encoding/json"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
var _ CredentialStore = &PropagatingCredentialStore{}
var _ PolicyManager = &PropagatingCredentialStore{}
type propagatingManagedPolicyLoader interface {
LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error)
}
type propagatingInlinePolicyLoader interface {
LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error)
}
type PropagatingCredentialStore struct {
CredentialStore
masterClient *wdclient.MasterClient
grpcDialOption grpc.DialOption
}
func NewPropagatingCredentialStore(upstream CredentialStore, masterClient *wdclient.MasterClient, grpcDialOption grpc.DialOption) *PropagatingCredentialStore {
return &PropagatingCredentialStore{
CredentialStore: upstream,
masterClient: masterClient,
grpcDialOption: grpcDialOption,
}
}
func (s *PropagatingCredentialStore) SetFilerAddressFunc(getFiler func() pb.ServerAddress, grpcDialOption grpc.DialOption) {
if setter, ok := s.CredentialStore.(FilerAddressSetter); ok {
setter.SetFilerAddressFunc(getFiler, grpcDialOption)
}
}
// WithS3InternalAdminAuth attaches a Bearer token signed with
// jwt.filer_signing.key to the outgoing context so the S3 gateway's internal
// gRPC handlers (IAM cache, lifecycle) accept the call. With no key configured
// it is a no-op, matching the S3 handler's checkAdminAuth. Returns the token's
// lifetime (0 = no expiry) so callers can cap any downstream timeout below it.
func WithS3InternalAdminAuth(ctx context.Context) (context.Context, time.Duration) {
signingKey := util.GetViper().GetString("jwt.filer_signing.key")
if signingKey == "" {
return ctx, 0
}
expiresAfterSec := util.GetViper().GetInt("jwt.filer_signing.expires_after_seconds")
token := security.GenJwtForFilerAdmin(security.SigningKey(signingKey), expiresAfterSec)
if token == "" {
return ctx, 0
}
return metadata.AppendToOutgoingContext(ctx, "authorization", security.BearerPrefix+string(token)), time.Duration(expiresAfterSec) * time.Second
}
func (s *PropagatingCredentialStore) propagateChange(ctx context.Context, fn func(context.Context, s3_pb.SeaweedS3IamCacheClient) error) {
if s.masterClient == nil {
return
}
// List S3 servers
var s3Servers []string
err := s.masterClient.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
glog.V(4).Infof("IAM: listing S3 servers (FilerGroup: '%s')", s.masterClient.FilerGroup)
resp, err := client.ListClusterNodes(ctx, &master_pb.ListClusterNodesRequest{
ClientType: cluster.S3Type,
FilerGroup: s.masterClient.FilerGroup,
})
if err != nil {
glog.Warningf("failed to list S3 servers: %v", err)
return err
}
for _, node := range resp.ClusterNodes {
s3Servers = append(s3Servers, node.Address)
}
return nil
})
if err != nil {
glog.Warningf("failed to list s3 servers via master client: %v", err)
return
}
glog.V(1).Infof("IAM: propagating change to %d S3 servers: %v", len(s3Servers), s3Servers)
// Mint the admin token after master discovery so master retries can't burn
// through the token lifetime before the peer fan-out begins. Cap the
// propagation deadline below the token's expiry so slower peers don't see
// an expired token.
authedCtx, tokenTTL := WithS3InternalAdminAuth(ctx)
propagateTimeout := 10 * time.Second
if tokenTTL > 0 && tokenTTL < propagateTimeout {
propagateTimeout = tokenTTL
}
propagateCtx, cancel := context.WithTimeout(authedCtx, propagateTimeout)
defer cancel()
var wg sync.WaitGroup
for _, server := range s3Servers {
wg.Add(1)
go func(server string) {
defer wg.Done()
err := pb.WithGrpcClient(context.Background(), false, 0, func(conn *grpc.ClientConn) error {
glog.V(4).Infof("IAM: successfully connected to S3 server %s for propagation", server)
client := s3_pb.NewSeaweedS3IamCacheClient(conn)
return fn(propagateCtx, client)
}, server, false, s.grpcDialOption)
if err != nil {
glog.Warningf("failed to propagate change to s3 server %s: %v", server, err)
}
}(server)
}
wg.Wait()
}
func (s *PropagatingCredentialStore) AttachUserPolicy(ctx context.Context, username string, policyName string) error {
glog.V(4).Infof("IAM: PropagatingCredentialStore.AttachUserPolicy %s -> %s", username, policyName)
if err := s.CredentialStore.AttachUserPolicy(ctx, username, policyName); err != nil {
return err
}
// Fetch updated identity to propagate
identity, err := s.CredentialStore.GetUser(ctx, username)
if err != nil {
glog.Warningf("failed to get user %s after attaching policy: %v", username, err)
return nil
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
return err
})
return nil
}
func (s *PropagatingCredentialStore) DetachUserPolicy(ctx context.Context, username string, policyName string) error {
glog.V(4).Infof("IAM: PropagatingCredentialStore.DetachUserPolicy %s -> %s", username, policyName)
if err := s.CredentialStore.DetachUserPolicy(ctx, username, policyName); err != nil {
return err
}
// Fetch updated identity to propagate
identity, err := s.CredentialStore.GetUser(ctx, username)
if err != nil {
glog.Warningf("failed to get user %s after detaching policy: %v", username, err)
return nil
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
return err
})
return nil
}
func (s *PropagatingCredentialStore) ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error) {
return s.CredentialStore.ListAttachedUserPolicies(ctx, username)
}
// SaveConfiguration overrides the embedded CredentialStore.SaveConfiguration
// so bulk identity / group updates also push to running S3 caches. The IAM
// API flow ends each handler with SaveConfiguration after recomputing
// identity.Actions (the legacy authorization field), so reusing PutIdentity
// here is what keeps inline-policy changes visible to S3 servers without
// requiring a restart. We diff against the prior store state so deletions
// also fan out (RemoveIdentity / RemoveGroup); without the diff a postgres
// user who got pruned by SaveConfiguration would linger in the S3 cache.
func (s *PropagatingCredentialStore) SaveConfiguration(ctx context.Context, config *iam_pb.S3ApiConfiguration) error {
priorUsers, priorErr := s.CredentialStore.ListUsers(ctx)
if priorErr != nil {
glog.V(1).Infof("failed to list users before SaveConfiguration; skipping deletion propagation: %v", priorErr)
priorUsers = nil
}
priorGroups, gPriorErr := s.CredentialStore.ListGroups(ctx)
if gPriorErr != nil {
glog.V(1).Infof("failed to list groups before SaveConfiguration; skipping deletion propagation: %v", gPriorErr)
priorGroups = nil
}
if err := s.CredentialStore.SaveConfiguration(ctx, config); err != nil {
return err
}
keptUsers := make(map[string]struct{}, len(config.Identities))
for _, ident := range config.Identities {
if ident != nil {
keptUsers[ident.Name] = struct{}{}
}
}
keptGroups := make(map[string]struct{}, len(config.Groups))
for _, g := range config.Groups {
if g != nil {
keptGroups[g.Name] = struct{}{}
}
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
for _, ident := range config.Identities {
if ident == nil {
continue
}
if _, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: ident}); err != nil {
return err
}
}
for _, g := range config.Groups {
if g == nil {
continue
}
if _, err := client.PutGroup(tx, &iam_pb.PutGroupRequest{Group: g}); err != nil {
return err
}
}
for _, name := range priorUsers {
if _, kept := keptUsers[name]; kept {
continue
}
if _, err := client.RemoveIdentity(tx, &iam_pb.RemoveIdentityRequest{Username: name}); err != nil {
return err
}
}
for _, name := range priorGroups {
if _, kept := keptGroups[name]; kept {
continue
}
if _, err := client.RemoveGroup(tx, &iam_pb.RemoveGroupRequest{GroupName: name}); err != nil {
return err
}
}
return nil
})
return nil
}
func (s *PropagatingCredentialStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateUser %s", identity.Name)
if err := s.CredentialStore.CreateUser(ctx, identity); err != nil {
return err
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
return err
})
return nil
}
func (s *PropagatingCredentialStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
glog.V(4).Infof("IAM: PropagatingCredentialStore.UpdateUser %s", username)
if err := s.CredentialStore.UpdateUser(ctx, username, identity); err != nil {
return err
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
if _, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity}); err != nil {
return err
}
if username != identity.Name {
if _, err := client.RemoveIdentity(tx, &iam_pb.RemoveIdentityRequest{Username: username}); err != nil {
return err
}
}
return nil
})
return nil
}
func (s *PropagatingCredentialStore) DeleteUser(ctx context.Context, username string) error {
glog.V(4).Infof("IAM: PropagatingCredentialStore.DeleteUser %s", username)
if err := s.CredentialStore.DeleteUser(ctx, username); err != nil {
return err
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.RemoveIdentity(tx, &iam_pb.RemoveIdentityRequest{Username: username})
return err
})
return nil
}
func (s *PropagatingCredentialStore) CreateAccessKey(ctx context.Context, username string, credential *iam_pb.Credential) error {
if err := s.CredentialStore.CreateAccessKey(ctx, username, credential); err != nil {
return err
}
// Fetch updated identity to propagate
identity, err := s.CredentialStore.GetUser(ctx, username)
if err != nil {
glog.Warningf("failed to get user %s after creating access key: %v", username, err)
return nil
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
return err
})
return nil
}
func (s *PropagatingCredentialStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
if err := s.CredentialStore.DeleteAccessKey(ctx, username, accessKey); err != nil {
return err
}
// Fetch updated identity to propagate
identity, err := s.CredentialStore.GetUser(ctx, username)
if err != nil {
glog.Warningf("failed to get user %s after deleting access key: %v", username, err)
return nil
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
return err
})
return nil
}
func (s *PropagatingCredentialStore) PutPolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
glog.V(4).Infof("IAM: PropagatingCredentialStore.PutPolicy %s", name)
if err := s.CredentialStore.PutPolicy(ctx, name, document); err != nil {
return err
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
content, err := json.Marshal(document)
if err != nil {
return err
}
_, err = client.PutPolicy(tx, &iam_pb.PutPolicyRequest{Name: name, Content: string(content)})
return err
})
return nil
}
func (s *PropagatingCredentialStore) DeletePolicy(ctx context.Context, name string) error {
glog.V(4).Infof("IAM: PropagatingCredentialStore.DeletePolicy %s", name)
if err := s.CredentialStore.DeletePolicy(ctx, name); err != nil {
return err
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.DeletePolicy(tx, &iam_pb.DeletePolicyRequest{Name: name})
return err
})
return nil
}
func (s *PropagatingCredentialStore) ListPolicyNames(ctx context.Context) ([]string, error) {
return s.CredentialStore.ListPolicyNames(ctx)
}
func (s *PropagatingCredentialStore) LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error) {
if loader, ok := s.CredentialStore.(propagatingManagedPolicyLoader); ok {
return loader.LoadManagedPolicies(ctx)
}
policies, err := s.CredentialStore.GetPolicies(ctx)
if err != nil {
return nil, err
}
managedPolicies := make([]*iam_pb.Policy, 0, len(policies))
for name, policyDocument := range policies {
content, err := json.Marshal(policyDocument)
if err != nil {
return nil, err
}
managedPolicies = append(managedPolicies, &iam_pb.Policy{
Name: name,
Content: string(content),
})
}
return managedPolicies, nil
}
func (s *PropagatingCredentialStore) LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
if loader, ok := s.CredentialStore.(propagatingInlinePolicyLoader); ok {
return loader.LoadInlinePolicies(ctx)
}
return nil, nil
}
func (s *PropagatingCredentialStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error {
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
return store.PutUserInlinePolicy(ctx, userName, policyName, document)
}
return nil
}
func (s *PropagatingCredentialStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) {
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
return store.GetUserInlinePolicy(ctx, userName, policyName)
}
return nil, nil
}
func (s *PropagatingCredentialStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error {
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
return store.DeleteUserInlinePolicy(ctx, userName, policyName)
}
return nil
}
func (s *PropagatingCredentialStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) {
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
return store.ListUserInlinePolicies(ctx, userName)
}
return nil, nil
}
func (s *PropagatingCredentialStore) PutGroupInlinePolicy(ctx context.Context, groupName, policyName string, document policy_engine.PolicyDocument) error {
if store, ok := s.CredentialStore.(GroupInlinePolicyStore); ok {
return store.PutGroupInlinePolicy(ctx, groupName, policyName, document)
}
return nil
}
func (s *PropagatingCredentialStore) GetGroupInlinePolicy(ctx context.Context, groupName, policyName string) (*policy_engine.PolicyDocument, error) {
if store, ok := s.CredentialStore.(GroupInlinePolicyStore); ok {
return store.GetGroupInlinePolicy(ctx, groupName, policyName)
}
return nil, nil
}
func (s *PropagatingCredentialStore) DeleteGroupInlinePolicy(ctx context.Context, groupName, policyName string) error {
if store, ok := s.CredentialStore.(GroupInlinePolicyStore); ok {
return store.DeleteGroupInlinePolicy(ctx, groupName, policyName)
}
return nil
}
func (s *PropagatingCredentialStore) ListGroupInlinePolicies(ctx context.Context, groupName string) ([]string, error) {
if store, ok := s.CredentialStore.(GroupInlinePolicyStore); ok {
return store.ListGroupInlinePolicies(ctx, groupName)
}
return nil, nil
}
func (s *PropagatingCredentialStore) LoadGroupInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
if loader, ok := s.CredentialStore.(GroupInlinePoliciesLoader); ok {
return loader.LoadGroupInlinePolicies(ctx)
}
return nil, nil
}
func (s *PropagatingCredentialStore) CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
if pm, ok := s.CredentialStore.(PolicyManager); ok {
if err := pm.CreatePolicy(ctx, name, document); err != nil {
return err
}
} else {
if err := s.CredentialStore.PutPolicy(ctx, name, document); err != nil {
return err
}
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
content, err := json.Marshal(document)
if err != nil {
return err
}
_, err = client.PutPolicy(tx, &iam_pb.PutPolicyRequest{Name: name, Content: string(content)})
return err
})
return nil
}
func (s *PropagatingCredentialStore) UpdatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
if pm, ok := s.CredentialStore.(PolicyManager); ok {
if err := pm.UpdatePolicy(ctx, name, document); err != nil {
return err
}
} else {
if err := s.CredentialStore.PutPolicy(ctx, name, document); err != nil {
return err
}
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
content, err := json.Marshal(document)
if err != nil {
return err
}
_, err = client.PutPolicy(tx, &iam_pb.PutPolicyRequest{Name: name, Content: string(content)})
return err
})
return nil
}
func (s *PropagatingCredentialStore) CreateServiceAccount(ctx context.Context, sa *iam_pb.ServiceAccount) error {
glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateServiceAccount %s (parent: %s)", sa.Id, sa.ParentUser)
if err := s.CredentialStore.CreateServiceAccount(ctx, sa); err != nil {
return err
}
// Fetch parent identity to propagate
identity, err := s.CredentialStore.GetUser(ctx, sa.ParentUser)
if err != nil {
glog.Warningf("failed to get parent user %s after creating service account: %v", sa.ParentUser, err)
return nil
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
return err
})
return nil
}
func (s *PropagatingCredentialStore) UpdateServiceAccount(ctx context.Context, id string, sa *iam_pb.ServiceAccount) error {
if err := s.CredentialStore.UpdateServiceAccount(ctx, id, sa); err != nil {
return err
}
// Fetch parent identity to propagate
identity, err := s.CredentialStore.GetUser(ctx, sa.ParentUser)
if err != nil {
glog.Warningf("failed to get parent user %s after updating service account: %v", sa.ParentUser, err)
return nil
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
return err
})
return nil
}
func (s *PropagatingCredentialStore) DeleteServiceAccount(ctx context.Context, id string) error {
// Retrieve SA first to get ParentUser
sa, err := s.CredentialStore.GetServiceAccount(ctx, id)
if err != nil {
// If accessing non-existent SA, just proceed to delete (idempotency)
// But we can't propagate to parent...
if err := s.CredentialStore.DeleteServiceAccount(ctx, id); err != nil {
return err
}
return nil
}
if err := s.CredentialStore.DeleteServiceAccount(ctx, id); err != nil {
return err
}
// Fetch parent identity to propagate
identity, err := s.CredentialStore.GetUser(ctx, sa.ParentUser)
if err != nil {
glog.Warningf("failed to get parent user %s after deleting service account: %v", sa.ParentUser, err)
return nil
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
return err
})
return nil
}
func (s *PropagatingCredentialStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
if group != nil {
glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateGroup %s", group.Name)
}
if err := s.CredentialStore.CreateGroup(ctx, group); err != nil {
return err
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.PutGroup(tx, &iam_pb.PutGroupRequest{Group: group})
return err
})
return nil
}
func (s *PropagatingCredentialStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
return s.CredentialStore.GetGroup(ctx, groupName)
}
func (s *PropagatingCredentialStore) DeleteGroup(ctx context.Context, groupName string) error {
glog.V(4).Infof("IAM: PropagatingCredentialStore.DeleteGroup %s", groupName)
if err := s.CredentialStore.DeleteGroup(ctx, groupName); err != nil {
return err
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.RemoveGroup(tx, &iam_pb.RemoveGroupRequest{GroupName: groupName})
return err
})
return nil
}
func (s *PropagatingCredentialStore) ListGroups(ctx context.Context) ([]string, error) {
return s.CredentialStore.ListGroups(ctx)
}
func (s *PropagatingCredentialStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
if group != nil {
glog.V(4).Infof("IAM: PropagatingCredentialStore.UpdateGroup %s", group.Name)
}
if err := s.CredentialStore.UpdateGroup(ctx, group); err != nil {
return err
}
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
_, err := client.PutGroup(tx, &iam_pb.PutGroupRequest{Group: group})
return err
})
return nil
}

View File

@@ -0,0 +1,64 @@
package credential
import (
"context"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc/metadata"
)
func TestWithS3InternalAdminAuth_NoKey_NoOp(t *testing.T) {
util.GetViper().Set("jwt.filer_signing.key", "")
ctx, ttl := WithS3InternalAdminAuth(context.Background())
if ttl != 0 {
t.Fatalf("expected zero TTL without key, got %v", ttl)
}
md, ok := metadata.FromOutgoingContext(ctx)
if ok && len(md.Get("authorization")) > 0 {
t.Fatalf("expected no authorization metadata without key, got %v", md.Get("authorization"))
}
}
func TestWithS3InternalAdminAuth_WithKey_AttachesBearer(t *testing.T) {
const k = "propagation-test-signing-key"
util.GetViper().Set("jwt.filer_signing.key", k)
defer util.GetViper().Set("jwt.filer_signing.key", "")
util.GetViper().Set("jwt.filer_signing.expires_after_seconds", 60)
ctx, ttl := WithS3InternalAdminAuth(context.Background())
if ttl != 60*time.Second {
t.Fatalf("expected 60s TTL, got %v", ttl)
}
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
t.Fatal("expected outgoing metadata to be set")
}
headers := md.Get("authorization")
if len(headers) != 1 {
t.Fatalf("expected one authorization header, got %v", headers)
}
if !strings.HasPrefix(headers[0], security.BearerPrefix) {
t.Fatalf("expected Bearer scheme, got %q", headers[0])
}
token := strings.TrimPrefix(headers[0], security.BearerPrefix)
parsed, err := security.DecodeJwt(security.SigningKey(k), security.EncodedJwt(token), &security.SeaweedFilerAdminClaims{})
if err != nil || parsed == nil || !parsed.Valid {
t.Fatalf("attached token failed signature validation: %v", err)
}
}
func TestWithS3InternalAdminAuth_ZeroExpiry_ZeroTTL(t *testing.T) {
const k = "propagation-test-signing-key"
util.GetViper().Set("jwt.filer_signing.key", k)
defer util.GetViper().Set("jwt.filer_signing.key", "")
util.GetViper().Set("jwt.filer_signing.expires_after_seconds", 0)
_, ttl := WithS3InternalAdminAuth(context.Background())
if ttl != 0 {
t.Fatalf("expected zero TTL for no-expiry token, got %v", ttl)
}
}

View File

@@ -0,0 +1,64 @@
package credential_test
import (
"context"
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
// Side-effect: register filer_etc so the existing
// TestCredentialStoreInterface / TestGetAvailableStores assertions
// (which expect filer_etc to always be present) stay satisfied now
// that this test file pulls in `memory` and the `len(Stores) == 0`
// skip path no longer fires.
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc"
)
// TestSaveConfigurationDelegatesWithoutMaster confirms the override delegates
// to the underlying store and the no-master fan-out path is a safe no-op.
// propagateChange already short-circuits when masterClient is nil, so we
// don't need to spin up a fake S3 IAM cache server here.
func TestSaveConfigurationDelegatesWithoutMaster(t *testing.T) {
ctx := context.Background()
upstream := &memory.MemoryStore{}
require.NoError(t, upstream.Initialize(nil, ""))
ps := credential.NewPropagatingCredentialStore(upstream, nil, nil)
cfg := &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}, {Name: "bob"}},
}
require.NoError(t, ps.SaveConfiguration(ctx, cfg))
users, err := upstream.ListUsers(ctx)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"alice", "bob"}, users)
}
// TestSaveConfigurationDiffSnapshotTakenBeforeWrite confirms that deletions
// the underlying SaveConfiguration prunes are visible to the propagation
// diff. We replay a second SaveConfiguration that drops "bob" and verify the
// underlying store reflects the deletion (the propagation fan-out is a
// no-op without masterClient but the diff snapshot pathway runs).
func TestSaveConfigurationDiffSnapshotTakenBeforeWrite(t *testing.T) {
ctx := context.Background()
upstream := &memory.MemoryStore{}
require.NoError(t, upstream.Initialize(nil, ""))
ps := credential.NewPropagatingCredentialStore(upstream, nil, nil)
require.NoError(t, ps.SaveConfiguration(ctx, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}, {Name: "bob"}},
}))
require.NoError(t, ps.SaveConfiguration(ctx, &iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{{Name: "alice"}},
}))
users, err := upstream.ListUsers(ctx)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"alice"}, users)
}

View File

@@ -0,0 +1,266 @@
package test
import (
"context"
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc"
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
_ "github.com/seaweedfs/seaweedfs/weed/credential/postgres"
)
func TestInlinePolicyOperations(t *testing.T) {
ctx := context.Background()
credentialManager, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "")
if err != nil {
t.Fatalf("Failed to create credential manager: %v", err)
}
store, ok := credentialManager.GetStore().(*memory.MemoryStore)
if !ok {
t.Fatal("Store is not a memory store")
}
userName := "testuser"
policyName := "read-bucket"
doc := policy_engine.PolicyDocument{
Version: "2012-10-17",
Statement: []policy_engine.PolicyStatement{
{
Effect: policy_engine.PolicyEffectAllow,
Action: policy_engine.NewStringOrStringSlice("s3:GetObject"),
Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::test-bucket/*"),
},
},
}
// Put
if err := store.PutUserInlinePolicy(ctx, userName, policyName, doc); err != nil {
t.Fatalf("PutUserInlinePolicy failed: %v", err)
}
// Get
got, err := store.GetUserInlinePolicy(ctx, userName, policyName)
if err != nil {
t.Fatalf("GetUserInlinePolicy failed: %v", err)
}
if got == nil {
t.Fatal("GetUserInlinePolicy returned nil")
}
if got.Version != "2012-10-17" {
t.Errorf("Expected version '2012-10-17', got '%s'", got.Version)
}
if len(got.Statement) != 1 {
t.Errorf("Expected 1 statement, got %d", len(got.Statement))
}
// Get non-existent
missing, err := store.GetUserInlinePolicy(ctx, userName, "no-such-policy")
if err != nil {
t.Fatalf("GetUserInlinePolicy for missing policy failed: %v", err)
}
if missing != nil {
t.Error("Expected nil for non-existent policy")
}
// Put second policy, same user
doc2 := policy_engine.PolicyDocument{
Version: "2012-10-17",
Statement: []policy_engine.PolicyStatement{
{
Effect: policy_engine.PolicyEffectAllow,
Action: policy_engine.NewStringOrStringSlice("s3:PutObject"),
Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::other-bucket/*"),
},
},
}
if err := store.PutUserInlinePolicy(ctx, userName, "write-bucket", doc2); err != nil {
t.Fatalf("PutUserInlinePolicy second policy failed: %v", err)
}
// List
names, err := store.ListUserInlinePolicies(ctx, userName)
if err != nil {
t.Fatalf("ListUserInlinePolicies failed: %v", err)
}
if len(names) != 2 {
t.Errorf("Expected 2 policies, got %d", len(names))
}
// List for non-existent user
emptyNames, err := store.ListUserInlinePolicies(ctx, "nobody")
if err != nil {
t.Fatalf("ListUserInlinePolicies for missing user failed: %v", err)
}
if len(emptyNames) != 0 {
t.Errorf("Expected 0 policies for missing user, got %d", len(emptyNames))
}
// LoadInlinePolicies (bulk)
all, err := store.LoadInlinePolicies(ctx)
if err != nil {
t.Fatalf("LoadInlinePolicies failed: %v", err)
}
if len(all) != 1 {
t.Errorf("Expected 1 user in LoadInlinePolicies, got %d", len(all))
}
if len(all[userName]) != 2 {
t.Errorf("Expected 2 policies for user in LoadInlinePolicies, got %d", len(all[userName]))
}
// Overwrite existing policy (upsert)
updatedDoc := policy_engine.PolicyDocument{
Version: "2012-10-17",
Statement: []policy_engine.PolicyStatement{
{
Effect: policy_engine.PolicyEffectDeny,
Action: policy_engine.NewStringOrStringSlice("s3:GetObject"),
Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::test-bucket/secret/*"),
},
},
}
if err := store.PutUserInlinePolicy(ctx, userName, policyName, updatedDoc); err != nil {
t.Fatalf("PutUserInlinePolicy overwrite failed: %v", err)
}
overwritten, err := store.GetUserInlinePolicy(ctx, userName, policyName)
if err != nil {
t.Fatalf("GetUserInlinePolicy after overwrite failed: %v", err)
}
if overwritten.Statement[0].Effect != policy_engine.PolicyEffectDeny {
t.Errorf("Expected Deny after overwrite, got %s", overwritten.Statement[0].Effect)
}
// Delete one policy
if err := store.DeleteUserInlinePolicy(ctx, userName, policyName); err != nil {
t.Fatalf("DeleteUserInlinePolicy failed: %v", err)
}
deleted, err := store.GetUserInlinePolicy(ctx, userName, policyName)
if err != nil {
t.Fatalf("GetUserInlinePolicy after delete failed: %v", err)
}
if deleted != nil {
t.Error("Expected nil after delete")
}
// Remaining policy still there
remaining, err := store.ListUserInlinePolicies(ctx, userName)
if err != nil {
t.Fatalf("ListUserInlinePolicies after delete failed: %v", err)
}
if len(remaining) != 1 {
t.Errorf("Expected 1 remaining policy, got %d", len(remaining))
}
// Delete last policy — user entry should be cleaned up
if err := store.DeleteUserInlinePolicy(ctx, userName, "write-bucket"); err != nil {
t.Fatalf("DeleteUserInlinePolicy last policy failed: %v", err)
}
allAfter, err := store.LoadInlinePolicies(ctx)
if err != nil {
t.Fatalf("LoadInlinePolicies after full cleanup failed: %v", err)
}
if len(allAfter) != 0 {
t.Errorf("Expected empty LoadInlinePolicies after cleanup, got %d users", len(allAfter))
}
}
// TestMemoryRenameUserMovesIdentityAndPolicies exercises the new
// credential.UserRenamer path on the memory store: the identity row,
// its access keys, and any inline policies all have to land under the
// new name in a single operation.
func TestMemoryRenameUserMovesIdentityAndPolicies(t *testing.T) {
ctx := context.Background()
cm, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "")
if err != nil {
t.Fatalf("Failed to create credential manager: %v", err)
}
const (
oldName = "renameme"
newName = "renamed"
policyName = "ReadBucket"
)
cred := &iam_pb.Credential{AccessKey: "AKIA-RENAME", SecretKey: "secret"}
if err := cm.CreateUser(ctx, &iam_pb.Identity{
Name: oldName,
Credentials: []*iam_pb.Credential{cred},
}); err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
doc := policy_engine.PolicyDocument{
Version: "2012-10-17",
Statement: []policy_engine.PolicyStatement{{
Effect: policy_engine.PolicyEffectAllow,
Action: policy_engine.NewStringOrStringSlice("s3:GetObject"),
Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::bucket/*"),
}},
}
if err := cm.PutUserInlinePolicy(ctx, oldName, policyName, doc); err != nil {
t.Fatalf("PutUserInlinePolicy failed: %v", err)
}
supported, err := cm.RenameUser(ctx, oldName, newName)
if err != nil {
t.Fatalf("RenameUser failed: %v", err)
}
if !supported {
t.Fatal("memory store should implement credential.UserRenamer")
}
if _, err := cm.GetUser(ctx, oldName); err == nil {
t.Errorf("expected old user %q to be gone after rename", oldName)
}
got, err := cm.GetUser(ctx, newName)
if err != nil {
t.Fatalf("GetUser(%q) after rename failed: %v", newName, err)
}
if got.Name != newName {
t.Errorf("renamed identity name = %q, want %q", got.Name, newName)
}
if len(got.Credentials) != 1 || got.Credentials[0].AccessKey != cred.AccessKey {
t.Errorf("renamed identity should still own its access key, got %+v", got.Credentials)
}
gotByKey, err := cm.GetUserByAccessKey(ctx, cred.AccessKey)
if err != nil {
t.Fatalf("GetUserByAccessKey failed: %v", err)
}
if gotByKey == nil || gotByKey.Name != newName {
t.Errorf("access key should resolve to %q, got %+v", newName, gotByKey)
}
gotPolicy, err := cm.GetUserInlinePolicy(ctx, newName, policyName)
if err != nil {
t.Fatalf("GetUserInlinePolicy under new name failed: %v", err)
}
if gotPolicy == nil {
t.Fatal("inline policy should be readable under the new name")
}
if gotPolicy.Statement[0].Effect != policy_engine.PolicyEffectAllow {
t.Errorf("policy effect: got %q want %q", gotPolicy.Statement[0].Effect, policy_engine.PolicyEffectAllow)
}
stalePolicy, err := cm.GetUserInlinePolicy(ctx, oldName, policyName)
if err != nil {
t.Fatalf("GetUserInlinePolicy under old name failed: %v", err)
}
if stalePolicy != nil {
t.Errorf("inline policy should be gone from the old name, got %+v", stalePolicy)
}
// Clean up — NewCredentialManager hands out a shared MemoryStore singleton
// so leftover state would leak into TestMemoryStoreIntegration.
if err := cm.DeleteUserInlinePolicy(ctx, newName, policyName); err != nil {
t.Fatalf("DeleteUserInlinePolicy cleanup failed: %v", err)
}
if err := cm.DeleteUser(ctx, newName); err != nil {
t.Fatalf("DeleteUser cleanup failed: %v", err)
}
}

View File

@@ -0,0 +1,121 @@
package test
import (
"context"
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
// Import all store implementations to register them
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc"
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
_ "github.com/seaweedfs/seaweedfs/weed/credential/postgres"
)
func TestStoreRegistration(t *testing.T) {
// Test that stores are registered
storeNames := credential.GetAvailableStores()
if len(storeNames) == 0 {
t.Fatal("No credential stores registered")
}
expectedStores := []string{string(credential.StoreTypeFilerEtc), string(credential.StoreTypeMemory), string(credential.StoreTypePostgres)}
// Verify all expected stores are present
for _, expected := range expectedStores {
found := false
for _, storeName := range storeNames {
if string(storeName) == expected {
found = true
break
}
}
if !found {
t.Errorf("Expected store not found: %s", expected)
}
}
t.Logf("Available stores: %v", storeNames)
}
func TestMemoryStoreIntegration(t *testing.T) {
// Test creating credential manager with memory store
config := util.GetViper()
cm, err := credential.NewCredentialManager(credential.StoreTypeMemory, config, "test.")
if err != nil {
t.Fatalf("Failed to create memory credential manager: %v", err)
}
defer cm.Shutdown()
// Test that the store is of the correct type
if cm.GetStore().GetName() != credential.StoreTypeMemory {
t.Errorf("Expected memory store, got %s", cm.GetStore().GetName())
}
// Test basic operations
ctx := context.Background()
// Create test user
testUser := &iam_pb.Identity{
Name: "testuser",
Actions: []string{"Read", "Write"},
Account: &iam_pb.Account{
Id: "123456789012",
DisplayName: "Test User",
EmailAddress: "test@example.com",
},
Credentials: []*iam_pb.Credential{
{
AccessKey: "AKIAIOSFODNN7EXAMPLE",
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
},
}
// Test CreateUser
err = cm.CreateUser(ctx, testUser)
if err != nil {
t.Fatalf("CreateUser failed: %v", err)
}
// Test GetUser
user, err := cm.GetUser(ctx, "testuser")
if err != nil {
t.Fatalf("GetUser failed: %v", err)
}
if user.Name != "testuser" {
t.Errorf("Expected user name 'testuser', got %s", user.Name)
}
// Test ListUsers
users, err := cm.ListUsers(ctx)
if err != nil {
t.Fatalf("ListUsers failed: %v", err)
}
if len(users) != 1 || users[0] != "testuser" {
t.Errorf("Expected ['testuser'], got %v", users)
}
// Test GetUserByAccessKey
userByKey, err := cm.GetUserByAccessKey(ctx, "AKIAIOSFODNN7EXAMPLE")
if err != nil {
t.Fatalf("GetUserByAccessKey failed: %v", err)
}
if userByKey.Name != "testuser" {
t.Errorf("Expected user name 'testuser', got %s", userByKey.Name)
}
// Test DeleteUser
err = cm.DeleteUser(ctx, "testuser")
if err != nil {
t.Fatalf("DeleteUser failed: %v", err)
}
// Verify user was deleted
_, err = cm.GetUser(ctx, "testuser")
if err != credential.ErrUserNotFound {
t.Errorf("Expected ErrUserNotFound, got %v", err)
}
}

View File

@@ -0,0 +1,147 @@
package test
import (
"context"
"testing"
"github.com/seaweedfs/seaweedfs/weed/credential"
"github.com/seaweedfs/seaweedfs/weed/credential/memory"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
// Import all store implementations to register them
_ "github.com/seaweedfs/seaweedfs/weed/credential/filer_etc"
_ "github.com/seaweedfs/seaweedfs/weed/credential/memory"
_ "github.com/seaweedfs/seaweedfs/weed/credential/postgres"
)
// TestPolicyManagement tests policy management across all credential stores
func TestPolicyManagement(t *testing.T) {
ctx := context.Background()
// Test with memory store (easiest to test)
credentialManager, err := credential.NewCredentialManager(credential.StoreTypeMemory, nil, "")
if err != nil {
t.Fatalf("Failed to create credential manager: %v", err)
}
// Test policy operations
testPolicyOperations(t, ctx, credentialManager)
}
func testPolicyOperations(t *testing.T, ctx context.Context, credentialManager *credential.CredentialManager) {
store := credentialManager.GetStore()
// Cast to memory store to access policy methods
memoryStore, ok := store.(*memory.MemoryStore)
if !ok {
t.Skip("Store is not a memory store")
}
// Test GetPolicies (should be empty initially)
policies, err := memoryStore.GetPolicies(ctx)
if err != nil {
t.Fatalf("Failed to get policies: %v", err)
}
if len(policies) != 0 {
t.Errorf("Expected 0 policies, got %d", len(policies))
}
// Test CreatePolicy
testPolicy := policy_engine.PolicyDocument{
Version: "2012-10-17",
Statement: []policy_engine.PolicyStatement{
{
Effect: policy_engine.PolicyEffectAllow,
Action: policy_engine.NewStringOrStringSlice("s3:GetObject"),
Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::test-bucket/*"),
},
},
}
err = memoryStore.CreatePolicy(ctx, "test-policy", testPolicy)
if err != nil {
t.Fatalf("Failed to create policy: %v", err)
}
// Test GetPolicies (should have 1 policy now)
policies, err = memoryStore.GetPolicies(ctx)
if err != nil {
t.Fatalf("Failed to get policies: %v", err)
}
if len(policies) != 1 {
t.Errorf("Expected 1 policy, got %d", len(policies))
}
// Verify policy content
policy, exists := policies["test-policy"]
if !exists {
t.Error("test-policy not found")
}
if policy.Version != "2012-10-17" {
t.Errorf("Expected policy version '2012-10-17', got '%s'", policy.Version)
}
if len(policy.Statement) != 1 {
t.Errorf("Expected 1 statement, got %d", len(policy.Statement))
}
// Test UpdatePolicy
updatedPolicy := policy_engine.PolicyDocument{
Version: "2012-10-17",
Statement: []policy_engine.PolicyStatement{
{
Effect: policy_engine.PolicyEffectAllow,
Action: policy_engine.NewStringOrStringSlice("s3:GetObject", "s3:PutObject"),
Resource: policy_engine.NewStringOrStringSlicePtr("arn:aws:s3:::test-bucket/*"),
},
},
}
err = memoryStore.UpdatePolicy(ctx, "test-policy", updatedPolicy)
if err != nil {
t.Fatalf("Failed to update policy: %v", err)
}
// Verify the update
policies, err = memoryStore.GetPolicies(ctx)
if err != nil {
t.Fatalf("Failed to get policies after update: %v", err)
}
updatedPolicyResult, exists := policies["test-policy"]
if !exists {
t.Error("test-policy not found after update")
}
if len(updatedPolicyResult.Statement) != 1 {
t.Errorf("Expected 1 statement after update, got %d", len(updatedPolicyResult.Statement))
}
if len(updatedPolicyResult.Statement[0].Action.Strings()) != 2 {
t.Errorf("Expected 2 actions after update, got %d", len(updatedPolicyResult.Statement[0].Action.Strings()))
}
// Test DeletePolicy
err = memoryStore.DeletePolicy(ctx, "test-policy")
if err != nil {
t.Fatalf("Failed to delete policy: %v", err)
}
// Verify deletion
policies, err = memoryStore.GetPolicies(ctx)
if err != nil {
t.Fatalf("Failed to get policies after deletion: %v", err)
}
if len(policies) != 0 {
t.Errorf("Expected 0 policies after deletion, got %d", len(policies))
}
}
// TestPolicyManagementWithFilerEtc tests policy management with filer_etc store
func TestPolicyManagementWithFilerEtc(t *testing.T) {
// Skip this test if we can't connect to a filer
t.Skip("Filer connection required for filer_etc store testing")
}
// TestPolicyManagementWithPostgres tests policy management with postgres store
func TestPolicyManagementWithPostgres(t *testing.T) {
// Skip this test if we can't connect to PostgreSQL
t.Skip("PostgreSQL connection required for postgres store testing")
}

View File

@@ -0,0 +1,34 @@
package credential
import (
"fmt"
"regexp"
)
var (
PolicyNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// ServiceAccountIdPattern matches sa:<parent-user>:<uuid>. The parent-user
// segment accepts every character allowed in an AWS IAM username
// (`[\w+=,.@-]+` per
// https://docs.aws.amazon.com/IAM/latest/APIReference/API_User.html)
// so service accounts created for users with e.g. `user@example.com`
// don't fail validation at the persistence layer.
ServiceAccountIdPattern = regexp.MustCompile(`^sa:[A-Za-z0-9_+=,.@-]+:[a-z0-9-]+$`)
)
func ValidatePolicyName(name string) error {
if !PolicyNamePattern.MatchString(name) {
return fmt.Errorf("invalid policy name: %s", name)
}
return nil
}
func ValidateServiceAccountId(id string) error {
if id == "" {
return fmt.Errorf("service account ID cannot be empty")
}
if !ServiceAccountIdPattern.MatchString(id) {
return fmt.Errorf("invalid service account ID: %s (expected format sa:<user>:<uuid>)", id)
}
return nil
}

View File

@@ -0,0 +1,49 @@
package credential
import "testing"
func TestValidateServiceAccountId(t *testing.T) {
cases := []struct {
name string
id string
wantErr bool
}{
{"simple", "sa:alice:abcdef0123456789", false},
{"uppercase parent", "sa:ALICE:abcdef0123456789", false},
{"hyphenated uuid suffix", "sa:alice:123e4567-e89b-12d3-a456-426614174000", false},
{"with hyphen", "sa:test-user:abcdef0123456789", false},
{"with underscore", "sa:test_user:abcdef0123456789", false},
// AWS IAM usernames accept `+=,.@-` in addition to alphanumerics
// and underscore. Service account IDs must accept them too, or
// callers with realistic usernames hit a validation error at the
// persistence layer. See
// https://docs.aws.amazon.com/IAM/latest/APIReference/API_User.html
{"email-style", "sa:alice@example.com:abcdef0123456789", false},
{"with dot", "sa:first.last:abcdef0123456789", false},
{"with plus", "sa:user+tag:abcdef0123456789", false},
{"with equals", "sa:user=prod:abcdef0123456789", false},
{"with comma", "sa:a,b:abcdef0123456789", false},
{"empty", "", true},
{"missing prefix", "alice:abcdef0123456789", true},
{"wrong prefix", "svc:alice:abcdef0123456789", true},
{"uppercase uuid", "sa:alice:ABCDEF0123456789", true},
{"missing uuid", "sa:alice:", true},
{"missing user", "sa::abcdef0123456789", true},
// Colon is not in the AWS IAM username set, and would break
// the colon-separated layout.
{"colon in user", "sa:a:b:abcdef0123456789", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateServiceAccountId(tc.id)
if tc.wantErr && err == nil {
t.Fatalf("expected error for %q, got nil", tc.id)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error for %q: %v", tc.id, err)
}
})
}
}