aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--weed/command/filer.go1
-rw-r--r--weed/command/iam.go27
-rw-r--r--weed/command/s3.go3
-rw-r--r--weed/command/server.go1
-rw-r--r--weed/iamapi/iamapi_management_handlers.go157
-rw-r--r--weed/iamapi/iamapi_response.go5
-rw-r--r--weed/iamapi/iamapi_test.go52
-rw-r--r--weed/s3api/auth_credentials.go70
-rw-r--r--weed/s3api/s3api_embedded_iam.go922
-rw-r--r--weed/s3api/s3api_embedded_iam_test.go1028
-rw-r--r--weed/s3api/s3api_server.go18
12 files changed, 2241 insertions, 44 deletions
diff --git a/.gitignore b/.gitignore
index cd240ab6d..2ddff99a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -124,3 +124,4 @@ ADVANCED_IAM_DEVELOPMENT_PLAN.md
*.log
weed-iam
test/kafka/kafka-client-loadtest/weed-linux-arm64
+coverage.out
diff --git a/weed/command/filer.go b/weed/command/filer.go
index 3f616e624..f07c605d7 100644
--- a/weed/command/filer.go
+++ b/weed/command/filer.go
@@ -134,6 +134,7 @@ func init() {
filerS3Options.idleTimeout = cmdFiler.Flag.Int("s3.idleTimeout", 120, "connection idle seconds")
filerS3Options.concurrentUploadLimitMB = cmdFiler.Flag.Int("s3.concurrentUploadLimitMB", 0, "limit total concurrent upload size for S3, 0 means unlimited")
filerS3Options.concurrentFileUploadLimit = cmdFiler.Flag.Int("s3.concurrentFileUploadLimit", 0, "limit number of concurrent file uploads for S3, 0 means unlimited")
+ filerS3Options.enableIam = cmdFiler.Flag.Bool("s3.iam", true, "enable embedded IAM API on the same S3 port")
// start webdav on filer
filerStartWebDav = cmdFiler.Flag.Bool("webdav", false, "whether to start webdav gateway")
diff --git a/weed/command/iam.go b/weed/command/iam.go
index 8fae7ec96..77f3a9014 100644
--- a/weed/command/iam.go
+++ b/weed/command/iam.go
@@ -44,13 +44,34 @@ func init() {
var cmdIam = &Command{
UsageLine: "iam [-port=8111] [-filer=<ip:port>[,<ip:port>]...] [-master=<ip:port>,<ip:port>]",
- Short: "start a iam API compatible server",
- Long: `start a iam API compatible server.
+ Short: "[DEPRECATED] start a standalone iam API compatible server",
+ Long: `[DEPRECATED] start a standalone iam API compatible server.
+
+ DEPRECATION NOTICE:
+ The standalone 'weed iam' command is deprecated and will be removed in a future release.
+
+ The IAM API is now embedded in the S3 server by default. Simply use 'weed s3' instead,
+ which provides both S3 and IAM APIs on the same port (enabled by default with -iam=true).
+
+ This simplifies deployment by running a single server instead of two separate servers,
+ following the pattern used by MinIO and Ceph RGW.
+
+ To use the embedded IAM API:
+ weed s3 -port=8333 # IAM API is available on the same port
+
+ To disable the embedded IAM API (if you prefer the old behavior):
+ weed s3 -iam=false # Run S3 without IAM
+ weed iam -port=8111 # Run IAM separately (deprecated)
Multiple filer addresses can be specified for high availability, separated by commas.`,
}
func runIam(cmd *Command, args []string) bool {
+ glog.Warningf("================================================================================")
+ glog.Warningf("DEPRECATION WARNING: 'weed iam' is deprecated and will be removed in a future release.")
+ glog.Warningf("The IAM API is now embedded in 'weed s3' by default (use -iam=true, which is the default).")
+ glog.Warningf("Please migrate to using 'weed s3' which provides both S3 and IAM APIs on the same port.")
+ glog.Warningf("================================================================================")
return iamStandaloneOptions.startIamServer()
}
@@ -89,7 +110,7 @@ func (iamopt *IamOptions) startIamServer() bool {
if iamApiServer_err != nil {
glog.Fatalf("IAM API Server startup error: %v", iamApiServer_err)
}
-
+
// Register shutdown handler to prevent goroutine leak
grace.OnInterrupt(func() {
iamApiServer.Shutdown()
diff --git a/weed/command/s3.go b/weed/command/s3.go
index 5f62e8e58..5691489f4 100644
--- a/weed/command/s3.go
+++ b/weed/command/s3.go
@@ -58,6 +58,7 @@ type S3Options struct {
idleTimeout *int
concurrentUploadLimitMB *int
concurrentFileUploadLimit *int
+ enableIam *bool
}
func init() {
@@ -86,6 +87,7 @@ func init() {
s3StandaloneOptions.idleTimeout = cmdS3.Flag.Int("idleTimeout", 120, "connection idle seconds")
s3StandaloneOptions.concurrentUploadLimitMB = cmdS3.Flag.Int("concurrentUploadLimitMB", 0, "limit total concurrent upload size, 0 means unlimited")
s3StandaloneOptions.concurrentFileUploadLimit = cmdS3.Flag.Int("concurrentFileUploadLimit", 0, "limit number of concurrent file uploads, 0 means unlimited")
+ s3StandaloneOptions.enableIam = cmdS3.Flag.Bool("iam", true, "enable embedded IAM API on the same port")
}
var cmdS3 = &Command{
@@ -279,6 +281,7 @@ func (s3opt *S3Options) startS3Server() bool {
IamConfig: iamConfigPath, // Advanced IAM config (optional)
ConcurrentUploadLimit: int64(*s3opt.concurrentUploadLimitMB) * 1024 * 1024,
ConcurrentFileUploadLimit: int64(*s3opt.concurrentFileUploadLimit),
+ EnableIam: *s3opt.enableIam, // Embedded IAM API (enabled by default)
})
if s3ApiServer_err != nil {
glog.Fatalf("S3 API Server startup error: %v", s3ApiServer_err)
diff --git a/weed/command/server.go b/weed/command/server.go
index 49aa15c6e..954e3b93f 100644
--- a/weed/command/server.go
+++ b/weed/command/server.go
@@ -173,6 +173,7 @@ func init() {
s3Options.idleTimeout = cmdServer.Flag.Int("s3.idleTimeout", 120, "connection idle seconds")
s3Options.concurrentUploadLimitMB = cmdServer.Flag.Int("s3.concurrentUploadLimitMB", 0, "limit total concurrent upload size for S3, 0 means unlimited")
s3Options.concurrentFileUploadLimit = cmdServer.Flag.Int("s3.concurrentFileUploadLimit", 0, "limit number of concurrent file uploads for S3, 0 means unlimited")
+ s3Options.enableIam = cmdServer.Flag.Bool("s3.iam", true, "enable embedded IAM API on the same S3 port")
sftpOptions.port = cmdServer.Flag.Int("sftp.port", 2022, "SFTP server listen port")
sftpOptions.sshPrivateKey = cmdServer.Flag.String("sftp.sshPrivateKey", "", "path to the SSH private key file for host authentication")
diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go
index 1a8f852cd..1985b042f 100644
--- a/weed/iamapi/iamapi_management_handlers.go
+++ b/weed/iamapi/iamapi_management_handlers.go
@@ -1,17 +1,22 @@
package iamapi
+// This file provides IAM API handlers for the standalone IAM server.
+// NOTE: There is code duplication with weed/s3api/s3api_embedded_iam.go.
+// See GitHub issue #7747 for the planned refactoring to extract common IAM logic
+// into a shared package.
+
import (
+ "crypto/rand"
"crypto/sha1"
"encoding/json"
"errors"
"fmt"
- "math/rand"
+ "math/big"
"net/http"
"net/url"
- "reflect"
+ "sort"
"strings"
"sync"
- "time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
@@ -38,8 +43,6 @@ const (
)
var (
- seededRand *rand.Rand = rand.New(
- rand.NewSource(time.Now().UnixNano()))
policyDocuments = map[string]*policy_engine.PolicyDocument{}
policyLock = sync.RWMutex{}
)
@@ -104,12 +107,45 @@ func Hash(s *string) string {
return fmt.Sprintf("%x", h.Sum(nil))
}
-func StringWithCharset(length int, charset string) string {
+// StringWithCharset generates a cryptographically secure random string.
+// Uses crypto/rand for security-sensitive credential generation.
+func StringWithCharset(length int, charset string) (string, error) {
+ if length <= 0 {
+ return "", fmt.Errorf("length must be positive, got %d", length)
+ }
+ if charset == "" {
+ return "", fmt.Errorf("charset must not be empty")
+ }
b := make([]byte, length)
for i := range b {
- b[i] = charset[seededRand.Intn(len(charset))]
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
+ if err != nil {
+ return "", fmt.Errorf("failed to generate random index: %w", err)
+ }
+ b[i] = charset[n.Int64()]
}
- return string(b)
+ return string(b), nil
+}
+
+// stringSlicesEqual compares two string slices for equality, ignoring order.
+// This is used instead of reflect.DeepEqual to avoid order-dependent comparisons.
+func stringSlicesEqual(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ // Make copies to avoid modifying the originals
+ aCopy := make([]string, len(a))
+ bCopy := make([]string, len(b))
+ copy(aCopy, a)
+ copy(bCopy, b)
+ sort.Strings(aCopy)
+ sort.Strings(bCopy)
+ for i := range aCopy {
+ if aCopy[i] != bCopy[i] {
+ return false
+ }
+ }
+ return true
}
func (iama *IamApiServer) ListUsers(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp ListUsersResponse) {
@@ -199,8 +235,7 @@ func (iama *IamApiServer) CreatePolicy(s3cfg *iam_pb.S3ApiConfiguration, values
resp.CreatePolicyResult.Policy.Arn = &arn
resp.CreatePolicyResult.Policy.PolicyId = &policyId
policies := Policies{}
- policyLock.Lock()
- defer policyLock.Unlock()
+ // Note: Lock is already held by DoActions, no need to acquire here
if err = iama.s3ApiConfig.GetPolicies(&policies); err != nil {
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
@@ -273,7 +308,8 @@ func (iama *IamApiServer) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
for resource, actions := range statements {
isEqAction := false
for i, statement := range policyDocument.Statement {
- if reflect.DeepEqual(statement.Action.Strings(), actions) {
+ // Use order-independent comparison to avoid duplicates from different action orderings
+ if stringSlicesEqual(statement.Action.Strings(), actions) {
policyDocument.Statement[i].Resource = policy_engine.NewStringOrStringSlice(append(
policyDocument.Statement[i].Resource.Strings(), resource)...)
isEqAction = true
@@ -300,11 +336,12 @@ func (iama *IamApiServer) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values
return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)}
}
-func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp PutUserPolicyResponse, err *IamError) {
+// DeleteUserPolicy removes the inline policy from a user (clears their actions).
+func (iama *IamApiServer) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DeleteUserPolicyResponse, err *IamError) {
userName := values.Get("UserName")
- for i, ident := range s3cfg.Identities {
+ for _, ident := range s3cfg.Identities {
if ident.Name == userName {
- s3cfg.Identities = append(s3cfg.Identities[:i], s3cfg.Identities[i+1:]...)
+ ident.Actions = nil
return resp, nil
}
}
@@ -348,11 +385,19 @@ func GetActions(policy *policy_engine.PolicyDocument) ([]string, error) {
return actions, nil
}
-func (iama *IamApiServer) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp CreateAccessKeyResponse) {
+func (iama *IamApiServer) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp CreateAccessKeyResponse, iamErr *IamError) {
userName := values.Get("UserName")
status := iam.StatusTypeActive
- accessKeyId := StringWithCharset(21, charsetUpper)
- secretAccessKey := StringWithCharset(42, charset)
+
+ accessKeyId, err := StringWithCharset(21, charsetUpper)
+ if err != nil {
+ return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate access key: %w", err)}
+ }
+ secretAccessKey, err := StringWithCharset(42, charset)
+ if err != nil {
+ return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate secret key: %w", err)}
+ }
+
resp.CreateAccessKeyResult.AccessKey.AccessKeyId = &accessKeyId
resp.CreateAccessKeyResult.AccessKey.SecretAccessKey = &secretAccessKey
resp.CreateAccessKeyResult.AccessKey.UserName = &userName
@@ -379,7 +424,7 @@ func (iama *IamApiServer) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, valu
},
)
}
- return resp
+ return resp, nil
}
func (iama *IamApiServer) DeleteAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (resp DeleteAccessKeyResponse) {
@@ -399,36 +444,60 @@ func (iama *IamApiServer) DeleteAccessKey(s3cfg *iam_pb.S3ApiConfiguration, valu
return resp
}
-// handleImplicitUsername adds username who signs the request to values if 'username' is not specified
-// According to https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/create-access-key.html/
-// "If you do not specify a user name, IAM determines the user name implicitly based on the Amazon Web
-// Services access key ID signing the request."
-func handleImplicitUsername(r *http.Request, values url.Values) {
+// handleImplicitUsername adds username who signs the request to values if 'username' is not specified.
+// According to AWS documentation: "If you do not specify a user name, IAM determines the user name
+// implicitly based on the Amazon Web Services access key ID signing the request."
+// This function extracts the AccessKeyId from the SigV4 credential and looks up the corresponding
+// identity name in the credential store.
+func (iama *IamApiServer) handleImplicitUsername(r *http.Request, values url.Values) {
if len(r.Header["Authorization"]) == 0 || values.Get("UserName") != "" {
return
}
- // get username who signs the request. For a typical Authorization:
- // "AWS4-HMAC-SHA256 Credential=197FSAQ7HHTA48X64O3A/20220420/test1/iam/aws4_request, SignedHeaders=content-type;
- // host;x-amz-date, Signature=6757dc6b3d7534d67e17842760310e99ee695408497f6edc4fdb84770c252dc8",
- // the "test1" will be extracted as the username
- glog.V(4).Infof("Authorization field: %v", r.Header["Authorization"][0])
+ // Log presence of auth header without exposing sensitive signature material
+ glog.V(4).Infof("Authorization header present, extracting access key")
+ // Parse AWS SigV4 Authorization header format:
+ // "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/iam/aws4_request, ..."
s := strings.Split(r.Header["Authorization"][0], "Credential=")
if len(s) < 2 {
return
}
s = strings.Split(s[1], ",")
- if len(s) < 2 {
+ if len(s) < 1 {
return
}
s = strings.Split(s[0], "/")
- if len(s) < 5 {
+ if len(s) < 1 {
+ return
+ }
+ // s[0] is the AccessKeyId
+ accessKeyId := s[0]
+ if accessKeyId == "" {
+ return
+ }
+ // Nil-guard: ensure iam is initialized before lookup
+ if iama.iam == nil {
+ glog.V(4).Infof("IAM not initialized, cannot look up access key")
+ return
+ }
+ // Look up the identity by access key to get the username
+ identity, _, found := iama.iam.LookupByAccessKey(accessKeyId)
+ if !found {
+ // Mask access key in logs - show only first 4 chars
+ maskedKey := accessKeyId
+ if len(accessKeyId) > 4 {
+ maskedKey = accessKeyId[:4] + "***"
+ }
+ glog.V(4).Infof("Access key %s not found in credential store", maskedKey)
return
}
- userName := s[2]
- values.Set("UserName", userName)
+ values.Set("UserName", identity.Name)
}
func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
+ // Lock to prevent concurrent read-modify-write race conditions
+ policyLock.Lock()
+ defer policyLock.Unlock()
+
if err := r.ParseForm(); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
@@ -449,7 +518,7 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
response = iama.ListUsers(s3cfg, values)
changed = false
case "ListAccessKeys":
- handleImplicitUsername(r, values)
+ iama.handleImplicitUsername(r, values)
response = iama.ListAccessKeys(s3cfg, values)
changed = false
case "CreateUser":
@@ -477,10 +546,15 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
return
}
case "CreateAccessKey":
- handleImplicitUsername(r, values)
- response = iama.CreateAccessKey(s3cfg, values)
+ iama.handleImplicitUsername(r, values)
+ response, iamError = iama.CreateAccessKey(s3cfg, values)
+ if iamError != nil {
+ glog.Errorf("CreateAccessKey: %+v", iamError.Error)
+ writeIamErrorResponse(w, r, iamError)
+ return
+ }
case "DeleteAccessKey":
- handleImplicitUsername(r, values)
+ iama.handleImplicitUsername(r, values)
response = iama.DeleteAccessKey(s3cfg, values)
case "CreatePolicy":
response, iamError = iama.CreatePolicy(s3cfg, values)
@@ -489,6 +563,9 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
+ // CreatePolicy persists the policy document via iama.s3ApiConfig.PutPolicies().
+ // The `changed` flag is false because this does not modify the main s3cfg.Identities configuration.
+ changed = false
case "PutUserPolicy":
var iamError *IamError
response, iamError = iama.PutUserPolicy(s3cfg, values)
@@ -525,6 +602,14 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
writeIamErrorResponse(w, r, &iamError)
return
}
+ // Reload in-memory identity maps so subsequent LookupByAccessKey calls
+ // can see newly created or deleted keys immediately
+ if iama.iam != nil {
+ if err := iama.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil {
+ glog.Warningf("Failed to reload IAM configuration after mutation: %v", err)
+ // Don't fail the request since the persistent save succeeded
+ }
+ }
}
s3err.WriteXMLResponse(w, r, http.StatusOK, response)
}
diff --git a/weed/iamapi/iamapi_response.go b/weed/iamapi/iamapi_response.go
index df9443f0d..fc68ce5a5 100644
--- a/weed/iamapi/iamapi_response.go
+++ b/weed/iamapi/iamapi_response.go
@@ -84,6 +84,11 @@ type PutUserPolicyResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"`
}
+type DeleteUserPolicyResponse struct {
+ CommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserPolicyResponse"`
+}
+
type GetUserPolicyResponse struct {
CommonResponse
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"`
diff --git a/weed/iamapi/iamapi_test.go b/weed/iamapi/iamapi_test.go
index 94c48aa7f..fa04d1ce9 100644
--- a/weed/iamapi/iamapi_test.go
+++ b/weed/iamapi/iamapi_test.go
@@ -1,6 +1,7 @@
package iamapi
import (
+ "encoding/json"
"encoding/xml"
"net/http"
"net/http/httptest"
@@ -14,6 +15,7 @@ import (
"github.com/gorilla/mux"
"github.com/jinzhu/copier"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+ "github.com/seaweedfs/seaweedfs/weed/s3api"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/stretchr/testify/assert"
)
@@ -244,22 +246,62 @@ func executeRequest(req *http.Request, v interface{}) (*httptest.ResponseRecorde
}
func TestHandleImplicitUsername(t *testing.T) {
+ // Create a mock IamApiServer with credential store
+ // The handleImplicitUsername function now looks up the username from the
+ // credential store based on AccessKeyId, not from the region field in the auth header.
+ // Note: Using obviously fake access keys to avoid CI secret scanner false positives
+
+ // Create IAM directly as struct literal (same pattern as other tests)
+ iam := &s3api.IdentityAccessManagement{}
+
+ // Load test credentials - map access key to identity name
+ testConfig := &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "testuser1",
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIATESTFAKEKEY000001", SecretKey: "testsecretfake"},
+ },
+ },
+ },
+ }
+ err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(t, testConfig))
+ if err != nil {
+ t.Fatalf("Failed to load test config: %v", err)
+ }
+
+ iama := &IamApiServer{
+ iam: iam,
+ }
+
var tests = []struct {
r *http.Request
values url.Values
userName string
}{
+ // No authorization header - should not set username
{&http.Request{}, url.Values{}, ""},
- {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=197FSAQ7HHTA48X64O3A/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=6757dc6b3d7534d67e17842760310e99ee695408497f6edc4fdb84770c252dc8"}}}, url.Values{}, "test1"},
- {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =197FSAQ7HHTA48X64O3A/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=6757dc6b3d7534d67e17842760310e99ee695408497f6edc4fdb84770c252dc8"}}}, url.Values{}, ""},
- {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=197FSAQ7HHTA48X64O3A/20220420/test1/iam/aws4_request SignedHeaders=content-type;host;x-amz-date Signature=6757dc6b3d7534d67e17842760310e99ee695408497f6edc4fdb84770c252dc8"}}}, url.Values{}, ""},
- {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=197FSAQ7HHTA48X64O3A/20220420/test1/iam, SignedHeaders=content-type;host;x-amz-date, Signature=6757dc6b3d7534d67e17842760310e99ee695408497f6edc4fdb84770c252dc8"}}}, url.Values{}, ""},
+ // Valid auth header with known access key - should look up and find "testuser1"
+ {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"},
+ // Malformed auth header (no Credential=) - should not set username
+ {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =AKIATESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
+ // Unknown access key - should not set username
+ {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
}
for i, test := range tests {
- handleImplicitUsername(test.r, test.values)
+ iama.handleImplicitUsername(test.r, test.values)
if un := test.values.Get("UserName"); un != test.userName {
t.Errorf("No.%d: Got: %v, Expected: %v", i, un, test.userName)
}
}
}
+
+func mustMarshalJSON(t *testing.T, v interface{}) []byte {
+ t.Helper()
+ data, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("failed to marshal JSON: %v", err)
+ }
+ return data
+}
diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go
index 7b5d9a262..c81fb3a88 100644
--- a/weed/s3api/auth_credentials.go
+++ b/weed/s3api/auth_credentials.go
@@ -427,6 +427,16 @@ func (iam *IdentityAccessManagement) lookupByAccessKey(accessKey string) (identi
return nil, nil, false
}
+// LookupByAccessKey is an exported wrapper for lookupByAccessKey.
+// It returns the identity and credential associated with the given access key.
+//
+// WARNING: The returned pointers reference internal data structures.
+// Callers MUST NOT modify the returned Identity or Credential objects.
+// If mutation is needed, make a copy first.
+func (iam *IdentityAccessManagement) LookupByAccessKey(accessKey string) (identity *Identity, cred *Credential, found bool) {
+ return iam.lookupByAccessKey(accessKey)
+}
+
func (iam *IdentityAccessManagement) lookupAnonymous() (identity *Identity, found bool) {
iam.m.RLock()
defer iam.m.RUnlock()
@@ -633,6 +643,66 @@ func (iam *IdentityAccessManagement) authRequest(r *http.Request, action Action)
}
+// AuthSignatureOnly performs only signature verification without any authorization checks.
+// This is used for IAM API operations where authorization is handled separately based on
+// the specific IAM action (e.g., self-service vs admin operations).
+// Returns the authenticated identity and any signature verification error.
+func (iam *IdentityAccessManagement) AuthSignatureOnly(r *http.Request) (*Identity, s3err.ErrorCode) {
+ var identity *Identity
+ var s3Err s3err.ErrorCode
+ var authType string
+ switch getRequestAuthType(r) {
+ case authTypeUnknown:
+ glog.V(3).Infof("unknown auth type")
+ r.Header.Set(s3_constants.AmzAuthType, "Unknown")
+ return identity, s3err.ErrAccessDenied
+ case authTypePresignedV2, authTypeSignedV2:
+ glog.V(3).Infof("v2 auth type")
+ identity, s3Err = iam.isReqAuthenticatedV2(r)
+ authType = "SigV2"
+ case authTypeStreamingSigned, authTypeSigned, authTypePresigned:
+ glog.V(3).Infof("v4 auth type")
+ identity, s3Err = iam.reqSignatureV4Verify(r)
+ authType = "SigV4"
+ case authTypePostPolicy:
+ glog.V(3).Infof("post policy auth type")
+ r.Header.Set(s3_constants.AmzAuthType, "PostPolicy")
+ return identity, s3err.ErrNone
+ case authTypeStreamingUnsigned:
+ glog.V(3).Infof("unsigned streaming upload")
+ return identity, s3err.ErrNone
+ case authTypeJWT:
+ glog.V(3).Infof("jwt auth type detected, iamIntegration != nil? %t", iam.iamIntegration != nil)
+ r.Header.Set(s3_constants.AmzAuthType, "Jwt")
+ if iam.iamIntegration != nil {
+ identity, s3Err = iam.authenticateJWTWithIAM(r)
+ authType = "Jwt"
+ } else {
+ glog.V(2).Infof("IAM integration is nil, returning ErrNotImplemented")
+ return identity, s3err.ErrNotImplemented
+ }
+ case authTypeAnonymous:
+ // Anonymous users cannot use IAM API
+ return identity, s3err.ErrAccessDenied
+ default:
+ return identity, s3err.ErrNotImplemented
+ }
+
+ if len(authType) > 0 {
+ r.Header.Set(s3_constants.AmzAuthType, authType)
+ }
+ if s3Err != s3err.ErrNone {
+ return identity, s3Err
+ }
+
+ // Set account ID header for downstream handlers
+ if identity != nil && identity.Account != nil {
+ r.Header.Set(s3_constants.AmzAccountId, identity.Account.Id)
+ }
+
+ return identity, s3err.ErrNone
+}
+
func (identity *Identity) canDo(action Action, bucket string, objectKey string) bool {
if identity.isAdmin() {
return true
diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go
new file mode 100644
index 000000000..e7a30b4c1
--- /dev/null
+++ b/weed/s3api/s3api_embedded_iam.go
@@ -0,0 +1,922 @@
+package s3api
+
+// This file provides IAM API functionality embedded in the S3 server.
+// NOTE: There is code duplication with weed/iamapi/iamapi_management_handlers.go.
+// See GitHub issue #7747 for the planned refactoring to extract common IAM logic
+// into a shared package.
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha1"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "math/big"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "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"
+ . "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
+ "google.golang.org/protobuf/proto"
+)
+
+// EmbeddedIamApi provides IAM API functionality embedded in the S3 server.
+// This allows running a single server that handles both S3 and IAM requests.
+type EmbeddedIamApi struct {
+ credentialManager *credential.CredentialManager
+ iam *IdentityAccessManagement
+ policyLock sync.RWMutex
+}
+
+// NewEmbeddedIamApi creates a new embedded IAM API handler.
+func NewEmbeddedIamApi(credentialManager *credential.CredentialManager, iam *IdentityAccessManagement) *EmbeddedIamApi {
+ return &EmbeddedIamApi{
+ credentialManager: credentialManager,
+ iam: iam,
+ }
+}
+
+// IAM response types
+type iamCommonResponse struct {
+ ResponseMetadata struct {
+ RequestId string `xml:"RequestId"`
+ } `xml:"ResponseMetadata"`
+}
+
+func (r *iamCommonResponse) SetRequestId() {
+ r.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano())
+}
+
+type iamListUsersResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListUsersResponse"`
+ ListUsersResult struct {
+ Users []*iam.User `xml:"Users>member"`
+ IsTruncated bool `xml:"IsTruncated"`
+ } `xml:"ListUsersResult"`
+}
+
+type iamListAccessKeysResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAccessKeysResponse"`
+ ListAccessKeysResult struct {
+ AccessKeyMetadata []*iam.AccessKeyMetadata `xml:"AccessKeyMetadata>member"`
+ IsTruncated bool `xml:"IsTruncated"`
+ } `xml:"ListAccessKeysResult"`
+}
+
+type iamDeleteAccessKeyResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteAccessKeyResponse"`
+}
+
+type iamCreatePolicyResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreatePolicyResponse"`
+ CreatePolicyResult struct {
+ Policy iam.Policy `xml:"Policy"`
+ } `xml:"CreatePolicyResult"`
+}
+
+type iamCreateUserResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateUserResponse"`
+ CreateUserResult struct {
+ User iam.User `xml:"User"`
+ } `xml:"CreateUserResult"`
+}
+
+type iamDeleteUserResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserResponse"`
+}
+
+type iamGetUserResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserResponse"`
+ GetUserResult struct {
+ User iam.User `xml:"User"`
+ } `xml:"GetUserResult"`
+}
+
+type iamUpdateUserResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateUserResponse"`
+}
+
+type iamCreateAccessKeyResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateAccessKeyResponse"`
+ CreateAccessKeyResult struct {
+ AccessKey iam.AccessKey `xml:"AccessKey"`
+ } `xml:"CreateAccessKeyResult"`
+}
+
+type iamPutUserPolicyResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"`
+}
+
+type iamDeleteUserPolicyResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserPolicyResponse"`
+}
+
+type iamGetUserPolicyResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserPolicyResponse"`
+ GetUserPolicyResult struct {
+ UserName string `xml:"UserName"`
+ PolicyName string `xml:"PolicyName"`
+ PolicyDocument string `xml:"PolicyDocument"`
+ } `xml:"GetUserPolicyResult"`
+}
+
+type iamErrorResponse struct {
+ iamCommonResponse
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ErrorResponse"`
+ Error struct {
+ iam.ErrorDetails
+ Type string `xml:"Type"`
+ } `xml:"Error"`
+}
+
+type iamError struct {
+ Code string
+ Error error
+}
+
+// Policies stores IAM policies
+type iamPolicies struct {
+ Policies map[string]policy_engine.PolicyDocument `json:"policies"`
+}
+
+const (
+ iamCharsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ iamCharset = iamCharsetUpper + "abcdefghijklmnopqrstuvwxyz/"
+ iamPolicyDocumentVersion = "2012-10-17"
+ iamUserDoesNotExist = "the user with name %s cannot be found."
+)
+
+// Statement action constants
+const (
+ iamStatementActionAdmin = "*"
+ iamStatementActionWrite = "Put*"
+ iamStatementActionWriteAcp = "PutBucketAcl"
+ iamStatementActionRead = "Get*"
+ iamStatementActionReadAcp = "GetBucketAcl"
+ iamStatementActionList = "List*"
+ iamStatementActionTagging = "Tagging*"
+ iamStatementActionDelete = "DeleteBucket*"
+)
+
+func iamHash(s *string) string {
+ h := sha1.New()
+ h.Write([]byte(*s))
+ return fmt.Sprintf("%x", h.Sum(nil))
+}
+
+// iamStringWithCharset generates a cryptographically secure random string.
+// Uses crypto/rand for security-sensitive credential generation.
+func iamStringWithCharset(length int, charset string) (string, error) {
+ if length <= 0 {
+ return "", fmt.Errorf("length must be positive, got %d", length)
+ }
+ if charset == "" {
+ return "", fmt.Errorf("charset must not be empty")
+ }
+ b := make([]byte, length)
+ for i := range b {
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
+ if err != nil {
+ return "", fmt.Errorf("failed to generate random index: %w", err)
+ }
+ b[i] = charset[n.Int64()]
+ }
+ return string(b), nil
+}
+
+// iamStringSlicesEqual compares two string slices for equality, ignoring order.
+// This is used instead of reflect.DeepEqual to avoid order-dependent comparisons.
+func iamStringSlicesEqual(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ // Make copies to avoid modifying the originals
+ aCopy := make([]string, len(a))
+ bCopy := make([]string, len(b))
+ copy(aCopy, a)
+ copy(bCopy, b)
+ sort.Strings(aCopy)
+ sort.Strings(bCopy)
+ for i := range aCopy {
+ if aCopy[i] != bCopy[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func iamMapToStatementAction(action string) string {
+ switch action {
+ case iamStatementActionAdmin:
+ return ACTION_ADMIN
+ case iamStatementActionWrite:
+ return ACTION_WRITE
+ case iamStatementActionWriteAcp:
+ return ACTION_WRITE_ACP
+ case iamStatementActionRead:
+ return ACTION_READ
+ case iamStatementActionReadAcp:
+ return ACTION_READ_ACP
+ case iamStatementActionList:
+ return ACTION_LIST
+ case iamStatementActionTagging:
+ return ACTION_TAGGING
+ case iamStatementActionDelete:
+ return ACTION_DELETE_BUCKET
+ default:
+ return ""
+ }
+}
+
+func iamMapToIdentitiesAction(action string) string {
+ switch action {
+ case ACTION_ADMIN:
+ return iamStatementActionAdmin
+ case ACTION_WRITE:
+ return iamStatementActionWrite
+ case ACTION_WRITE_ACP:
+ return iamStatementActionWriteAcp
+ case ACTION_READ:
+ return iamStatementActionRead
+ case ACTION_READ_ACP:
+ return iamStatementActionReadAcp
+ case ACTION_LIST:
+ return iamStatementActionList
+ case ACTION_TAGGING:
+ return iamStatementActionTagging
+ case ACTION_DELETE_BUCKET:
+ return iamStatementActionDelete
+ default:
+ return ""
+ }
+}
+
+func newIamErrorResponse(errCode string, errMsg string) iamErrorResponse {
+ errorResp := iamErrorResponse{}
+ errorResp.Error.Type = "Sender"
+ errorResp.Error.Code = &errCode
+ errorResp.Error.Message = &errMsg
+ return errorResp
+}
+
+func (e *EmbeddedIamApi) writeIamErrorResponse(w http.ResponseWriter, r *http.Request, iamErr *iamError) {
+ if iamErr == nil {
+ glog.Errorf("No error found")
+ return
+ }
+
+ errCode := iamErr.Code
+ errMsg := iamErr.Error.Error()
+ glog.Errorf("IAM Response %+v", errMsg)
+
+ errorResp := newIamErrorResponse(errCode, errMsg)
+ internalErrorResponse := newIamErrorResponse(iam.ErrCodeServiceFailureException, "Internal server error")
+
+ switch errCode {
+ case iam.ErrCodeNoSuchEntityException:
+ s3err.WriteXMLResponse(w, r, http.StatusNotFound, errorResp)
+ case iam.ErrCodeEntityAlreadyExistsException:
+ s3err.WriteXMLResponse(w, r, http.StatusConflict, errorResp)
+ case iam.ErrCodeMalformedPolicyDocumentException, iam.ErrCodeInvalidInputException:
+ s3err.WriteXMLResponse(w, r, http.StatusBadRequest, errorResp)
+ case iam.ErrCodeServiceFailureException:
+ s3err.WriteXMLResponse(w, r, http.StatusInternalServerError, internalErrorResponse)
+ default:
+ s3err.WriteXMLResponse(w, r, http.StatusInternalServerError, internalErrorResponse)
+ }
+}
+
+// GetS3ApiConfiguration loads the S3 API configuration from the credential manager.
+func (e *EmbeddedIamApi) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error {
+ config, err := e.credentialManager.LoadConfiguration(context.Background())
+ if err != nil {
+ return fmt.Errorf("failed to load configuration: %w", err)
+ }
+ proto.Merge(s3cfg, config)
+ return nil
+}
+
+// PutS3ApiConfiguration saves the S3 API configuration to the credential manager.
+func (e *EmbeddedIamApi) PutS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error {
+ return e.credentialManager.SaveConfiguration(context.Background(), s3cfg)
+}
+
+// ListUsers lists all IAM users.
+func (e *EmbeddedIamApi) ListUsers(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) iamListUsersResponse {
+ var resp iamListUsersResponse
+ for _, ident := range s3cfg.Identities {
+ resp.ListUsersResult.Users = append(resp.ListUsersResult.Users, &iam.User{UserName: &ident.Name})
+ }
+ return resp
+}
+
+// ListAccessKeys lists access keys for a user.
+func (e *EmbeddedIamApi) ListAccessKeys(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) iamListAccessKeysResponse {
+ var resp iamListAccessKeysResponse
+ status := iam.StatusTypeActive
+ userName := values.Get("UserName")
+ for _, ident := range s3cfg.Identities {
+ if userName != "" && userName != ident.Name {
+ continue
+ }
+ for _, cred := range ident.Credentials {
+ resp.ListAccessKeysResult.AccessKeyMetadata = append(resp.ListAccessKeysResult.AccessKeyMetadata,
+ &iam.AccessKeyMetadata{UserName: &ident.Name, AccessKeyId: &cred.AccessKey, Status: &status},
+ )
+ }
+ }
+ return resp
+}
+
+// CreateUser creates a new IAM user.
+func (e *EmbeddedIamApi) CreateUser(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamCreateUserResponse, *iamError) {
+ var resp iamCreateUserResponse
+ userName := values.Get("UserName")
+
+ // Validate UserName is not empty
+ if userName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")}
+ }
+
+ // Check for duplicate user
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == userName {
+ return resp, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("user %s already exists", userName)}
+ }
+ }
+
+ resp.CreateUserResult.User.UserName = &userName
+ s3cfg.Identities = append(s3cfg.Identities, &iam_pb.Identity{Name: userName})
+ return resp, nil
+}
+
+// DeleteUser deletes an IAM user.
+func (e *EmbeddedIamApi) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName string) (iamDeleteUserResponse, *iamError) {
+ var resp iamDeleteUserResponse
+ for i, ident := range s3cfg.Identities {
+ if userName == ident.Name {
+ s3cfg.Identities = append(s3cfg.Identities[:i], s3cfg.Identities[i+1:]...)
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
+}
+
+// GetUser gets an IAM user.
+func (e *EmbeddedIamApi) GetUser(s3cfg *iam_pb.S3ApiConfiguration, userName string) (iamGetUserResponse, *iamError) {
+ var resp iamGetUserResponse
+ for _, ident := range s3cfg.Identities {
+ if userName == ident.Name {
+ resp.GetUserResult.User = iam.User{UserName: &ident.Name}
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
+}
+
+// UpdateUser updates an IAM user.
+func (e *EmbeddedIamApi) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamUpdateUserResponse, *iamError) {
+ var resp iamUpdateUserResponse
+ userName := values.Get("UserName")
+ newUserName := values.Get("NewUserName")
+ if newUserName != "" {
+ for _, ident := range s3cfg.Identities {
+ if userName == ident.Name {
+ ident.Name = newUserName
+ return resp, nil
+ }
+ }
+ } else {
+ return resp, nil
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
+}
+
+// CreateAccessKey creates an access key for a user.
+func (e *EmbeddedIamApi) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamCreateAccessKeyResponse, *iamError) {
+ var resp iamCreateAccessKeyResponse
+ userName := values.Get("UserName")
+ status := iam.StatusTypeActive
+
+ accessKeyId, err := iamStringWithCharset(21, iamCharsetUpper)
+ if err != nil {
+ return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate access key: %w", err)}
+ }
+ secretAccessKey, err := iamStringWithCharset(42, iamCharset)
+ if err != nil {
+ return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to generate secret key: %w", err)}
+ }
+
+ resp.CreateAccessKeyResult.AccessKey.AccessKeyId = &accessKeyId
+ resp.CreateAccessKeyResult.AccessKey.SecretAccessKey = &secretAccessKey
+ resp.CreateAccessKeyResult.AccessKey.UserName = &userName
+ resp.CreateAccessKeyResult.AccessKey.Status = &status
+
+ for _, ident := range s3cfg.Identities {
+ if userName == ident.Name {
+ ident.Credentials = append(ident.Credentials,
+ &iam_pb.Credential{AccessKey: accessKeyId, SecretKey: secretAccessKey})
+ return resp, nil
+ }
+ }
+ // User not found - return error instead of implicitly creating the user
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
+}
+
+// DeleteAccessKey deletes an access key for a user.
+func (e *EmbeddedIamApi) DeleteAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) iamDeleteAccessKeyResponse {
+ var resp iamDeleteAccessKeyResponse
+ userName := values.Get("UserName")
+ accessKeyId := values.Get("AccessKeyId")
+ for _, ident := range s3cfg.Identities {
+ if userName == ident.Name {
+ for i, cred := range ident.Credentials {
+ if cred.AccessKey == accessKeyId {
+ ident.Credentials = append(ident.Credentials[:i], ident.Credentials[i+1:]...)
+ break
+ }
+ }
+ break
+ }
+ }
+ return resp
+}
+
+// GetPolicyDocument parses a policy document string.
+func (e *EmbeddedIamApi) GetPolicyDocument(policy *string) (policy_engine.PolicyDocument, error) {
+ var policyDocument policy_engine.PolicyDocument
+ if err := json.Unmarshal([]byte(*policy), &policyDocument); err != nil {
+ return policy_engine.PolicyDocument{}, err
+ }
+ return policyDocument, nil
+}
+
+// CreatePolicy validates and creates a new IAM managed policy.
+// NOTE: Currently this only validates the policy document and returns policy metadata.
+// The policy is not persisted to a managed policy store. To apply permissions to a user,
+// use PutUserPolicy which stores the policy inline on the user's identity.
+// TODO: Implement managed policy storage for full AWS IAM compatibility (ListPolicies, GetPolicy, AttachUserPolicy).
+func (e *EmbeddedIamApi) CreatePolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamCreatePolicyResponse, *iamError) {
+ var resp iamCreatePolicyResponse
+ policyName := values.Get("PolicyName")
+ policyDocumentString := values.Get("PolicyDocument")
+ _, err := e.GetPolicyDocument(&policyDocumentString)
+ if err != nil {
+ return resp, &iamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
+ }
+ policyId := iamHash(&policyDocumentString)
+ arn := fmt.Sprintf("arn:aws:iam:::policy/%s", policyName)
+ resp.CreatePolicyResult.Policy.PolicyName = &policyName
+ resp.CreatePolicyResult.Policy.Arn = &arn
+ resp.CreatePolicyResult.Policy.PolicyId = &policyId
+ return resp, nil
+}
+
+// getActions extracts actions from a policy document.
+// S3 ARN format: arn:aws:s3:::bucket or arn:aws:s3:::bucket/path/*
+// res[5] contains the bucket and optional path after :::
+func (e *EmbeddedIamApi) getActions(policy *policy_engine.PolicyDocument) ([]string, error) {
+ var actions []string
+
+ for _, statement := range policy.Statement {
+ if statement.Effect != policy_engine.PolicyEffectAllow {
+ return nil, fmt.Errorf("not a valid effect: '%s'. Only 'Allow' is possible", statement.Effect)
+ }
+ for _, resource := range statement.Resource.Strings() {
+ res := strings.Split(resource, ":")
+ if len(res) != 6 || res[0] != "arn" || res[1] != "aws" || res[2] != "s3" {
+ continue
+ }
+ for _, action := range statement.Action.Strings() {
+ act := strings.Split(action, ":")
+ if len(act) != 2 || act[0] != "s3" {
+ continue
+ }
+ statementAction := iamMapToStatementAction(act[1])
+ if statementAction == "" {
+ return nil, fmt.Errorf("not a valid action: '%s'", act[1])
+ }
+
+ resourcePath := res[5]
+ if resourcePath == "*" {
+ // Wildcard - applies to all buckets
+ actions = append(actions, statementAction)
+ continue
+ }
+
+ // Parse bucket and optional object path
+ // Examples: "mybucket", "mybucket/*", "mybucket/prefix/*"
+ bucket, objectPath, hasSep := strings.Cut(resourcePath, "/")
+ if bucket == "" {
+ continue // Invalid: empty bucket name
+ }
+
+ if !hasSep || objectPath == "" || objectPath == "*" {
+ // Bucket-level or bucket/* - use just bucket name
+ actions = append(actions, fmt.Sprintf("%s:%s", statementAction, bucket))
+ } else {
+ // Path-specific: bucket/path/* -> Action:bucket/path
+ // Remove trailing /* if present for cleaner action format
+ objectPath = strings.TrimSuffix(objectPath, "/*")
+ objectPath = strings.TrimSuffix(objectPath, "*")
+ if objectPath == "" {
+ actions = append(actions, fmt.Sprintf("%s:%s", statementAction, bucket))
+ } else {
+ actions = append(actions, fmt.Sprintf("%s:%s/%s", statementAction, bucket, objectPath))
+ }
+ }
+ }
+ }
+ }
+
+ if len(actions) == 0 {
+ return nil, fmt.Errorf("no valid actions found in policy document")
+ }
+ return actions, nil
+}
+
+// PutUserPolicy attaches a policy to a user.
+func (e *EmbeddedIamApi) PutUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamPutUserPolicyResponse, *iamError) {
+ var resp iamPutUserPolicyResponse
+ userName := values.Get("UserName")
+ policyDocumentString := values.Get("PolicyDocument")
+ policyDocument, err := e.GetPolicyDocument(&policyDocumentString)
+ if err != nil {
+ return resp, &iamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
+ }
+ actions, err := e.getActions(&policyDocument)
+ if err != nil {
+ return resp, &iamError{Code: iam.ErrCodeMalformedPolicyDocumentException, Error: err}
+ }
+ glog.V(3).Infof("PutUserPolicy: actions=%v", actions)
+ for _, ident := range s3cfg.Identities {
+ if userName != ident.Name {
+ continue
+ }
+ ident.Actions = actions
+ return resp, nil
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("the user with name %s cannot be found", userName)}
+}
+
+// GetUserPolicy gets the policy attached to a user.
+func (e *EmbeddedIamApi) GetUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamGetUserPolicyResponse, *iamError) {
+ var resp iamGetUserPolicyResponse
+ userName := values.Get("UserName")
+ policyName := values.Get("PolicyName")
+ for _, ident := range s3cfg.Identities {
+ if userName != ident.Name {
+ continue
+ }
+
+ resp.GetUserPolicyResult.UserName = userName
+ resp.GetUserPolicyResult.PolicyName = policyName
+ if len(ident.Actions) == 0 {
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: errors.New("no actions found")}
+ }
+
+ policyDocument := policy_engine.PolicyDocument{Version: iamPolicyDocumentVersion}
+ statements := make(map[string][]string)
+ for _, action := range ident.Actions {
+ // Action format: "ActionType" (global) or "ActionType:bucket" or "ActionType:bucket/path"
+ actionType, bucketPath, hasPath := strings.Cut(action, ":")
+ var resource string
+ if !hasPath {
+ // Global action (no bucket specified)
+ resource = "*"
+ } else if strings.Contains(bucketPath, "/") {
+ // Path-specific: bucket/path -> arn:aws:s3:::bucket/path/*
+ resource = fmt.Sprintf("arn:aws:s3:::%s/*", bucketPath)
+ } else {
+ // Bucket-level: bucket -> arn:aws:s3:::bucket/*
+ resource = fmt.Sprintf("arn:aws:s3:::%s/*", bucketPath)
+ }
+ statements[resource] = append(statements[resource],
+ fmt.Sprintf("s3:%s", iamMapToIdentitiesAction(actionType)),
+ )
+ }
+ for resource, actions := range statements {
+ isEqAction := false
+ for i, statement := range policyDocument.Statement {
+ // Use order-independent comparison to avoid duplicates from different action orderings
+ if iamStringSlicesEqual(statement.Action.Strings(), actions) {
+ policyDocument.Statement[i].Resource = policy_engine.NewStringOrStringSlice(append(
+ policyDocument.Statement[i].Resource.Strings(), resource)...)
+ isEqAction = true
+ break
+ }
+ }
+ if isEqAction {
+ continue
+ }
+ policyDocumentStatement := policy_engine.PolicyStatement{
+ Effect: policy_engine.PolicyEffectAllow,
+ Action: policy_engine.NewStringOrStringSlice(actions...),
+ Resource: policy_engine.NewStringOrStringSlice(resource),
+ }
+ policyDocument.Statement = append(policyDocument.Statement, policyDocumentStatement)
+ }
+ policyDocumentJSON, err := json.Marshal(policyDocument)
+ if err != nil {
+ return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
+ }
+ resp.GetUserPolicyResult.PolicyDocument = string(policyDocumentJSON)
+ return resp, nil
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
+}
+
+// DeleteUserPolicy removes the inline policy from a user (clears their actions).
+func (e *EmbeddedIamApi) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamDeleteUserPolicyResponse, *iamError) {
+ var resp iamDeleteUserPolicyResponse
+ userName := values.Get("UserName")
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == userName {
+ ident.Actions = nil
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
+}
+
+// handleImplicitUsername adds username who signs the request to values if 'username' is not specified.
+// According to AWS documentation: "If you do not specify a user name, IAM determines the user name
+// implicitly based on the Amazon Web Services access key ID signing the request."
+// This function extracts the AccessKeyId from the SigV4 credential and looks up the corresponding
+// identity name in the credential store.
+func (e *EmbeddedIamApi) handleImplicitUsername(r *http.Request, values url.Values) {
+ if len(r.Header["Authorization"]) == 0 || values.Get("UserName") != "" {
+ return
+ }
+ // Log presence of auth header without exposing sensitive signature material
+ glog.V(4).Infof("Authorization header present, extracting access key")
+ // Parse AWS SigV4 Authorization header format:
+ // "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/iam/aws4_request, ..."
+ s := strings.Split(r.Header["Authorization"][0], "Credential=")
+ if len(s) < 2 {
+ return
+ }
+ s = strings.Split(s[1], ",")
+ if len(s) < 1 {
+ return
+ }
+ s = strings.Split(s[0], "/")
+ if len(s) < 1 {
+ return
+ }
+ // s[0] is the AccessKeyId
+ accessKeyId := s[0]
+ if accessKeyId == "" {
+ return
+ }
+ // Nil-guard: ensure iam is initialized before lookup
+ if e.iam == nil {
+ glog.V(4).Infof("IAM not initialized, cannot look up access key")
+ return
+ }
+ // Look up the identity by access key to get the username
+ identity, _, found := e.iam.LookupByAccessKey(accessKeyId)
+ if !found {
+ // Mask access key in logs - show only first 4 chars
+ maskedKey := accessKeyId
+ if len(accessKeyId) > 4 {
+ maskedKey = accessKeyId[:4] + "***"
+ }
+ glog.V(4).Infof("Access key %s not found in credential store", maskedKey)
+ return
+ }
+ values.Set("UserName", identity.Name)
+}
+
+// iamSelfServiceActions are actions that users can perform on their own resources without admin rights.
+// According to AWS IAM, users can manage their own access keys without requiring full admin permissions.
+var iamSelfServiceActions = map[string]bool{
+ "CreateAccessKey": true,
+ "DeleteAccessKey": true,
+ "ListAccessKeys": true,
+ "GetUser": true,
+ "UpdateAccessKey": true,
+}
+
+// iamRequiresAdminForOthers returns true if the action requires admin rights when operating on other users.
+func iamRequiresAdminForOthers(action string) bool {
+ return iamSelfServiceActions[action]
+}
+
+// AuthIam provides IAM-specific authentication that allows self-service operations.
+// Users can manage their own access keys without admin rights, but need admin for operations on other users.
+// The action parameter is accepted for interface compatibility with cb.Limit but is not used
+// since IAM permission checking is done based on the IAM Action parameter in the request.
+func (e *EmbeddedIamApi) AuthIam(f http.HandlerFunc, _ Action) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ // If auth is not enabled, allow all
+ if !e.iam.isEnabled() {
+ f(w, r)
+ return
+ }
+
+ // Parse form to get Action and UserName
+ if err := r.ParseForm(); err != nil {
+ s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
+ return
+ }
+
+ action := r.Form.Get("Action")
+ targetUserName := r.PostForm.Get("UserName")
+
+ // Authenticate the request using signature-only verification.
+ // This bypasses S3 authorization checks (identity.canDo) since IAM operations
+ // have their own permission model based on self-service vs admin operations.
+ identity, errCode := e.iam.AuthSignatureOnly(r)
+ if errCode != s3err.ErrNone {
+ s3err.WriteErrorResponse(w, r, errCode)
+ return
+ }
+
+ // IAM API requests must be authenticated - reject nil identity
+ // (can happen for authTypePostPolicy or authTypeStreamingUnsigned)
+ if identity == nil {
+ s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
+ return
+ }
+
+ // Store identity in context
+ if identity != nil && identity.Name != "" {
+ ctx := SetIdentityNameInContext(r.Context(), identity.Name)
+ ctx = SetIdentityInContext(ctx, identity)
+ r = r.WithContext(ctx)
+ }
+
+ // Check permissions based on action type
+ if iamRequiresAdminForOthers(action) {
+ // Self-service action: allow if operating on own resources or no target specified
+ if targetUserName == "" || targetUserName == identity.Name {
+ // Self-service: allowed
+ f(w, r)
+ return
+ }
+ // Operating on another user: require admin
+ if !identity.isAdmin() {
+ s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
+ return
+ }
+ } else {
+ // All other IAM actions require admin (CreateUser, DeleteUser, PutUserPolicy, etc.)
+ if !identity.isAdmin() {
+ s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
+ return
+ }
+ }
+
+ f(w, r)
+ }
+}
+
+// DoActions handles IAM API actions.
+func (e *EmbeddedIamApi) DoActions(w http.ResponseWriter, r *http.Request) {
+ // Lock to prevent concurrent read-modify-write race conditions
+ e.policyLock.Lock()
+ defer e.policyLock.Unlock()
+
+ if err := r.ParseForm(); err != nil {
+ s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
+ return
+ }
+ values := r.PostForm
+ s3cfg := &iam_pb.S3ApiConfiguration{}
+ if err := e.GetS3ApiConfiguration(s3cfg); err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
+ s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
+ return
+ }
+
+ glog.V(4).Infof("IAM DoActions: %+v", values)
+ var response interface{}
+ var iamErr *iamError
+ changed := true
+ switch r.Form.Get("Action") {
+ case "ListUsers":
+ response = e.ListUsers(s3cfg, values)
+ changed = false
+ case "ListAccessKeys":
+ e.handleImplicitUsername(r, values)
+ response = e.ListAccessKeys(s3cfg, values)
+ changed = false
+ case "CreateUser":
+ response, iamErr = e.CreateUser(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ case "GetUser":
+ userName := values.Get("UserName")
+ response, iamErr = e.GetUser(s3cfg, userName)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ changed = false
+ case "UpdateUser":
+ response, iamErr = e.UpdateUser(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ case "DeleteUser":
+ userName := values.Get("UserName")
+ response, iamErr = e.DeleteUser(s3cfg, userName)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ case "CreateAccessKey":
+ e.handleImplicitUsername(r, values)
+ response, iamErr = e.CreateAccessKey(s3cfg, values)
+ if iamErr != nil {
+ glog.Errorf("CreateAccessKey: %+v", iamErr.Error)
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ case "DeleteAccessKey":
+ e.handleImplicitUsername(r, values)
+ response = e.DeleteAccessKey(s3cfg, values)
+ case "CreatePolicy":
+ response, iamErr = e.CreatePolicy(s3cfg, values)
+ if iamErr != nil {
+ glog.Errorf("CreatePolicy: %+v", iamErr.Error)
+ s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
+ return
+ }
+ // CreatePolicy only validates the policy document and returns metadata.
+ // Policies are not stored separately; they are attached inline via PutUserPolicy.
+ changed = false
+ case "PutUserPolicy":
+ response, iamErr = e.PutUserPolicy(s3cfg, values)
+ if iamErr != nil {
+ glog.Errorf("PutUserPolicy: %+v", iamErr.Error)
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ case "GetUserPolicy":
+ response, iamErr = e.GetUserPolicy(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ changed = false
+ case "DeleteUserPolicy":
+ response, iamErr = e.DeleteUserPolicy(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ default:
+ errNotImplemented := s3err.GetAPIError(s3err.ErrNotImplemented)
+ errorResponse := iamErrorResponse{}
+ errorResponse.Error.Code = &errNotImplemented.Code
+ errorResponse.Error.Message = &errNotImplemented.Description
+ s3err.WriteXMLResponse(w, r, errNotImplemented.HTTPStatusCode, errorResponse)
+ return
+ }
+ if changed {
+ if err := e.PutS3ApiConfiguration(s3cfg); err != nil {
+ iamErr = &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ // Reload in-memory identity maps so subsequent LookupByAccessKey calls
+ // can see newly created or deleted keys immediately
+ if err := e.iam.LoadS3ApiConfigurationFromCredentialManager(); err != nil {
+ glog.Warningf("Failed to reload IAM configuration after mutation: %v", err)
+ // Don't fail the request since the persistent save succeeded
+ }
+ }
+ // Set RequestId for AWS compatibility
+ if r, ok := response.(interface{ SetRequestId() }); ok {
+ r.SetRequestId()
+ }
+ s3err.WriteXMLResponse(w, r, http.StatusOK, response)
+}
diff --git a/weed/s3api/s3api_embedded_iam_test.go b/weed/s3api/s3api_embedded_iam_test.go
new file mode 100644
index 000000000..81839084b
--- /dev/null
+++ b/weed/s3api/s3api_embedded_iam_test.go
@@ -0,0 +1,1028 @@
+package s3api
+
+import (
+ "encoding/json"
+ "encoding/xml"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/aws/session"
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/gorilla/mux"
+ "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+ "github.com/stretchr/testify/assert"
+ "google.golang.org/protobuf/proto"
+)
+
+// EmbeddedIamApiForTest is a testable version of EmbeddedIamApi
+type EmbeddedIamApiForTest struct {
+ *EmbeddedIamApi
+ mockConfig *iam_pb.S3ApiConfiguration
+}
+
+func NewEmbeddedIamApiForTest() *EmbeddedIamApiForTest {
+ e := &EmbeddedIamApiForTest{
+ EmbeddedIamApi: &EmbeddedIamApi{
+ iam: &IdentityAccessManagement{},
+ },
+ mockConfig: &iam_pb.S3ApiConfiguration{},
+ }
+ return e
+}
+
+// Override GetS3ApiConfiguration for testing
+func (e *EmbeddedIamApiForTest) GetS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error {
+ // Use proto.Clone for proper deep copy semantics
+ if e.mockConfig != nil {
+ cloned := proto.Clone(e.mockConfig).(*iam_pb.S3ApiConfiguration)
+ proto.Merge(s3cfg, cloned)
+ }
+ return nil
+}
+
+// Override PutS3ApiConfiguration for testing
+func (e *EmbeddedIamApiForTest) PutS3ApiConfiguration(s3cfg *iam_pb.S3ApiConfiguration) error {
+ // Use proto.Clone for proper deep copy semantics
+ e.mockConfig = proto.Clone(s3cfg).(*iam_pb.S3ApiConfiguration)
+ return nil
+}
+
+// DoActions handles IAM API actions for testing
+func (e *EmbeddedIamApiForTest) DoActions(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+ values := r.PostForm
+ s3cfg := &iam_pb.S3ApiConfiguration{}
+ if err := e.GetS3ApiConfiguration(s3cfg); err != nil {
+ http.Error(w, "Internal error", http.StatusInternalServerError)
+ return
+ }
+
+ var response interface{}
+ var iamErr *iamError
+ changed := true
+
+ switch r.Form.Get("Action") {
+ case "ListUsers":
+ response = e.ListUsers(s3cfg, values)
+ changed = false
+ case "ListAccessKeys":
+ e.handleImplicitUsername(r, values)
+ response = e.ListAccessKeys(s3cfg, values)
+ changed = false
+ case "CreateUser":
+ response, iamErr = e.CreateUser(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ case "GetUser":
+ userName := values.Get("UserName")
+ response, iamErr = e.GetUser(s3cfg, userName)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ changed = false
+ case "UpdateUser":
+ response, iamErr = e.UpdateUser(s3cfg, values)
+ if iamErr != nil {
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+ case "DeleteUser":
+ userName := values.Get("UserName")
+ response, iamErr = e.DeleteUser(s3cfg, userName)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ case "CreateAccessKey":
+ e.handleImplicitUsername(r, values)
+ response, iamErr = e.CreateAccessKey(s3cfg, values)
+ if iamErr != nil {
+ http.Error(w, "Internal error", http.StatusInternalServerError)
+ return
+ }
+ case "DeleteAccessKey":
+ e.handleImplicitUsername(r, values)
+ response = e.DeleteAccessKey(s3cfg, values)
+ case "CreatePolicy":
+ response, iamErr = e.CreatePolicy(s3cfg, values)
+ if iamErr != nil {
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+ case "PutUserPolicy":
+ response, iamErr = e.PutUserPolicy(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ case "GetUserPolicy":
+ response, iamErr = e.GetUserPolicy(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ changed = false
+ case "DeleteUserPolicy":
+ response, iamErr = e.DeleteUserPolicy(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ default:
+ http.Error(w, "Not implemented", http.StatusNotImplemented)
+ return
+ }
+
+ if changed {
+ if err := e.PutS3ApiConfiguration(s3cfg); err != nil {
+ http.Error(w, "Internal error", http.StatusInternalServerError)
+ return
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/xml")
+ w.WriteHeader(http.StatusOK)
+ xmlBytes, err := xml.Marshal(response)
+ if err != nil {
+ // This should not happen in tests, but log it for debugging
+ http.Error(w, "Internal error: failed to marshal response", http.StatusInternalServerError)
+ return
+ }
+ _, _ = w.Write(xmlBytes)
+}
+
+// executeEmbeddedIamRequest executes an IAM request against the given API instance.
+// If v is non-nil, the response body is unmarshalled into it.
+func executeEmbeddedIamRequest(api *EmbeddedIamApiForTest, req *http.Request, v interface{}) (*httptest.ResponseRecorder, error) {
+ rr := httptest.NewRecorder()
+ apiRouter := mux.NewRouter().SkipClean(true)
+ apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
+ apiRouter.ServeHTTP(rr, req)
+ if v != nil {
+ if err := xml.Unmarshal(rr.Body.Bytes(), v); err != nil {
+ return rr, err
+ }
+ }
+ return rr, nil
+}
+
+// embeddedIamErrorResponseForTest is used for parsing IAM error responses in tests
+type embeddedIamErrorResponseForTest struct {
+ Error struct {
+ Code string `xml:"Code"`
+ Message string `xml:"Message"`
+ } `xml:"Error"`
+}
+
+func extractEmbeddedIamErrorCodeAndMessage(response *httptest.ResponseRecorder) (string, string) {
+ var er embeddedIamErrorResponseForTest
+ if err := xml.Unmarshal(response.Body.Bytes(), &er); err != nil {
+ return "", ""
+ }
+ return er.Error.Code, er.Error.Message
+}
+
+// TestEmbeddedIamCreateUser tests creating a user via the embedded IAM API
+func TestEmbeddedIamCreateUser(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{}
+
+ userName := aws.String("TestUser")
+ params := &iam.CreateUserInput{UserName: userName}
+ req, _ := iam.New(session.New()).CreateUserRequest(params)
+ _ = req.Build()
+ out := iamCreateUserResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+
+ // Verify response contains correct username
+ assert.NotNil(t, out.CreateUserResult.User.UserName)
+ assert.Equal(t, "TestUser", *out.CreateUserResult.User.UserName)
+
+ // Verify user was persisted in config
+ assert.Len(t, api.mockConfig.Identities, 1)
+ assert.Equal(t, "TestUser", api.mockConfig.Identities[0].Name)
+}
+
+// TestEmbeddedIamListUsers tests listing users via the embedded IAM API
+func TestEmbeddedIamListUsers(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {Name: "User1"},
+ {Name: "User2"},
+ },
+ }
+
+ params := &iam.ListUsersInput{}
+ req, _ := iam.New(session.New()).ListUsersRequest(params)
+ _ = req.Build()
+ out := iamListUsersResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+
+ // Verify response contains the users
+ assert.Len(t, out.ListUsersResult.Users, 2)
+}
+
+// TestEmbeddedIamListAccessKeys tests listing access keys via the embedded IAM API
+func TestEmbeddedIamListAccessKeys(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ svc := iam.New(session.New())
+ params := &iam.ListAccessKeysInput{}
+ req, _ := svc.ListAccessKeysRequest(params)
+ _ = req.Build()
+ out := iamListAccessKeysResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+}
+
+// TestEmbeddedIamGetUser tests getting a user via the embedded IAM API
+func TestEmbeddedIamGetUser(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {Name: "TestUser"},
+ },
+ }
+
+ userName := aws.String("TestUser")
+ params := &iam.GetUserInput{UserName: userName}
+ req, _ := iam.New(session.New()).GetUserRequest(params)
+ _ = req.Build()
+ out := iamGetUserResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+
+ // Verify response contains correct username
+ assert.NotNil(t, out.GetUserResult.User.UserName)
+ assert.Equal(t, "TestUser", *out.GetUserResult.User.UserName)
+}
+
+// TestEmbeddedIamCreatePolicy tests creating a policy via the embedded IAM API
+func TestEmbeddedIamCreatePolicy(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ params := &iam.CreatePolicyInput{
+ PolicyName: aws.String("S3-read-only-example-bucket"),
+ PolicyDocument: aws.String(`
+ {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:Get*",
+ "s3:List*"
+ ],
+ "Resource": [
+ "arn:aws:s3:::EXAMPLE-BUCKET",
+ "arn:aws:s3:::EXAMPLE-BUCKET/*"
+ ]
+ }
+ ]
+ }`),
+ }
+ req, _ := iam.New(session.New()).CreatePolicyRequest(params)
+ _ = req.Build()
+ out := iamCreatePolicyResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+
+ // Verify response contains policy metadata
+ assert.NotNil(t, out.CreatePolicyResult.Policy.PolicyName)
+ assert.Equal(t, "S3-read-only-example-bucket", *out.CreatePolicyResult.Policy.PolicyName)
+ assert.NotNil(t, out.CreatePolicyResult.Policy.Arn)
+ assert.NotNil(t, out.CreatePolicyResult.Policy.PolicyId)
+}
+
+// TestEmbeddedIamPutUserPolicy tests attaching a policy to a user
+func TestEmbeddedIamPutUserPolicy(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {Name: "TestUser"},
+ },
+ }
+
+ userName := aws.String("TestUser")
+ params := &iam.PutUserPolicyInput{
+ UserName: userName,
+ PolicyName: aws.String("S3-read-only-example-bucket"),
+ PolicyDocument: aws.String(
+ `{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:Get*",
+ "s3:List*"
+ ],
+ "Resource": [
+ "arn:aws:s3:::EXAMPLE-BUCKET",
+ "arn:aws:s3:::EXAMPLE-BUCKET/*"
+ ]
+ }
+ ]
+ }`),
+ }
+ req, _ := iam.New(session.New()).PutUserPolicyRequest(params)
+ _ = req.Build()
+ out := iamPutUserPolicyResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+
+ // Verify policy was attached to the user (actions should be set)
+ assert.Len(t, api.mockConfig.Identities, 1)
+ assert.NotEmpty(t, api.mockConfig.Identities[0].Actions)
+}
+
+// TestEmbeddedIamPutUserPolicyError tests error handling when user doesn't exist
+func TestEmbeddedIamPutUserPolicyError(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{}
+
+ userName := aws.String("InvalidUser")
+ params := &iam.PutUserPolicyInput{
+ UserName: userName,
+ PolicyName: aws.String("S3-read-only-example-bucket"),
+ PolicyDocument: aws.String(
+ `{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:Get*",
+ "s3:List*"
+ ],
+ "Resource": [
+ "arn:aws:s3:::EXAMPLE-BUCKET",
+ "arn:aws:s3:::EXAMPLE-BUCKET/*"
+ ]
+ }
+ ]
+ }`),
+ }
+ req, _ := iam.New(session.New()).PutUserPolicyRequest(params)
+ _ = req.Build()
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusNotFound, response.Code)
+
+ expectedCode := "NoSuchEntity"
+ code, _ := extractEmbeddedIamErrorCodeAndMessage(response)
+ assert.Equal(t, expectedCode, code)
+}
+
+// TestEmbeddedIamGetUserPolicy tests getting a user's policy
+func TestEmbeddedIamGetUserPolicy(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "TestUser",
+ Actions: []string{"Read", "List"},
+ },
+ },
+ }
+
+ userName := aws.String("TestUser")
+ params := &iam.GetUserPolicyInput{
+ UserName: userName,
+ PolicyName: aws.String("S3-read-only-example-bucket"),
+ }
+ req, _ := iam.New(session.New()).GetUserPolicyRequest(params)
+ _ = req.Build()
+ out := iamGetUserPolicyResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+}
+
+// TestEmbeddedIamDeleteUserPolicy tests deleting a user's policy (clears actions)
+func TestEmbeddedIamDeleteUserPolicy(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "TestUser",
+ Actions: []string{"Read", "Write", "List"},
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIATEST12345", SecretKey: "secret"},
+ },
+ },
+ },
+ }
+
+ // Use direct form post for DeleteUserPolicy
+ form := url.Values{}
+ form.Set("Action", "DeleteUserPolicy")
+ form.Set("UserName", "TestUser")
+ form.Set("PolicyName", "TestPolicy")
+
+ req, _ := http.NewRequest("POST", "/", nil)
+ req.PostForm = form
+ req.Form = form
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ rr := httptest.NewRecorder()
+ apiRouter := mux.NewRouter().SkipClean(true)
+ apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
+ apiRouter.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ // CRITICAL: Verify user still exists (was NOT deleted)
+ assert.Len(t, api.mockConfig.Identities, 1, "User should NOT be deleted")
+ assert.Equal(t, "TestUser", api.mockConfig.Identities[0].Name)
+
+ // Verify credentials are still intact
+ assert.Len(t, api.mockConfig.Identities[0].Credentials, 1, "Credentials should NOT be deleted")
+ assert.Equal(t, "AKIATEST12345", api.mockConfig.Identities[0].Credentials[0].AccessKey)
+
+ // Verify actions/policy was cleared
+ assert.Nil(t, api.mockConfig.Identities[0].Actions, "Actions should be cleared")
+}
+
+// TestEmbeddedIamDeleteUserPolicyUserNotFound tests error when user doesn't exist
+func TestEmbeddedIamDeleteUserPolicyUserNotFound(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{}
+
+ form := url.Values{}
+ form.Set("Action", "DeleteUserPolicy")
+ form.Set("UserName", "NonExistentUser")
+ form.Set("PolicyName", "TestPolicy")
+
+ req, _ := http.NewRequest("POST", "/", nil)
+ req.PostForm = form
+ req.Form = form
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ rr := httptest.NewRecorder()
+ apiRouter := mux.NewRouter().SkipClean(true)
+ apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
+ apiRouter.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusNotFound, rr.Code)
+}
+
+// TestEmbeddedIamUpdateUser tests updating a user
+func TestEmbeddedIamUpdateUser(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {Name: "TestUser"},
+ },
+ }
+
+ userName := aws.String("TestUser")
+ newUserName := aws.String("TestUser-New")
+ params := &iam.UpdateUserInput{NewUserName: newUserName, UserName: userName}
+ req, _ := iam.New(session.New()).UpdateUserRequest(params)
+ _ = req.Build()
+ out := iamUpdateUserResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+}
+
+// TestEmbeddedIamDeleteUser tests deleting a user
+func TestEmbeddedIamDeleteUser(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {Name: "TestUser-New"},
+ },
+ }
+
+ userName := aws.String("TestUser-New")
+ params := &iam.DeleteUserInput{UserName: userName}
+ req, _ := iam.New(session.New()).DeleteUserRequest(params)
+ _ = req.Build()
+ out := iamDeleteUserResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+}
+
+// TestEmbeddedIamCreateAccessKey tests creating an access key
+func TestEmbeddedIamCreateAccessKey(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {Name: "TestUser"},
+ },
+ }
+
+ userName := aws.String("TestUser")
+ params := &iam.CreateAccessKeyInput{UserName: userName}
+ req, _ := iam.New(session.New()).CreateAccessKeyRequest(params)
+ _ = req.Build()
+ out := iamCreateAccessKeyResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+
+ // Verify response contains access key credentials
+ assert.NotNil(t, out.CreateAccessKeyResult.AccessKey.AccessKeyId)
+ assert.NotEmpty(t, *out.CreateAccessKeyResult.AccessKey.AccessKeyId)
+ assert.NotNil(t, out.CreateAccessKeyResult.AccessKey.SecretAccessKey)
+ assert.NotEmpty(t, *out.CreateAccessKeyResult.AccessKey.SecretAccessKey)
+ assert.NotNil(t, out.CreateAccessKeyResult.AccessKey.UserName)
+ assert.Equal(t, "TestUser", *out.CreateAccessKeyResult.AccessKey.UserName)
+
+ // Verify credentials were persisted
+ assert.Len(t, api.mockConfig.Identities[0].Credentials, 1)
+}
+
+// TestEmbeddedIamDeleteAccessKey tests deleting an access key via direct form post
+func TestEmbeddedIamDeleteAccessKey(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "TestUser",
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIATEST12345", SecretKey: "secret"},
+ },
+ },
+ },
+ }
+
+ // Use direct form post since AWS SDK may format differently
+ form := url.Values{}
+ form.Set("Action", "DeleteAccessKey")
+ form.Set("UserName", "TestUser")
+ form.Set("AccessKeyId", "AKIATEST12345")
+
+ req, _ := http.NewRequest("POST", "/", nil)
+ req.PostForm = form
+ req.Form = form
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ rr := httptest.NewRecorder()
+ apiRouter := mux.NewRouter().SkipClean(true)
+ apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
+ apiRouter.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+
+ // Verify the access key was deleted
+ assert.Len(t, api.mockConfig.Identities[0].Credentials, 0)
+}
+
+// TestEmbeddedIamHandleImplicitUsername tests implicit username extraction from authorization header
+func TestEmbeddedIamHandleImplicitUsername(t *testing.T) {
+ // Create IAM with test credentials - the handleImplicitUsername function now looks
+ // up the username from the credential store based on AccessKeyId
+ // Note: Using obviously fake access keys to avoid secret scanner false positives
+ iam := &IdentityAccessManagement{}
+ testConfig := &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "testuser1",
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIATESTFAKEKEY000001", SecretKey: "testsecretfake"},
+ },
+ },
+ },
+ }
+ err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig))
+ if err != nil {
+ t.Fatalf("Failed to load test config: %v", err)
+ }
+
+ embeddedApi := &EmbeddedIamApi{
+ iam: iam,
+ }
+
+ var tests = []struct {
+ r *http.Request
+ values url.Values
+ userName string
+ }{
+ // No authorization header - should not set username
+ {&http.Request{}, url.Values{}, ""},
+ // Valid auth header with known access key - should look up and find "testuser1"
+ {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTFAKEKEY000001/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, "testuser1"},
+ // Malformed auth header (no Credential=) - should not set username
+ {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 =AKIATESTFAKEKEY000001/20220420/test1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
+ // Unknown access key - should not set username
+ {&http.Request{Header: http.Header{"Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIATESTUNKNOWN000000/20220420/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=fakesignature0123456789abcdef"}}}, url.Values{}, ""},
+ }
+
+ for i, test := range tests {
+ embeddedApi.handleImplicitUsername(test.r, test.values)
+ if un := test.values.Get("UserName"); un != test.userName {
+ t.Errorf("No.%d: Got: %v, Expected: %v", i, un, test.userName)
+ }
+ }
+}
+
+func mustMarshalJSON(v interface{}) []byte {
+ data, err := json.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ return data
+}
+
+// TestEmbeddedIamFullWorkflow tests a complete user lifecycle
+func TestEmbeddedIamFullWorkflow(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{}
+
+ // 1. Create user
+ t.Run("CreateUser", func(t *testing.T) {
+ userName := aws.String("WorkflowUser")
+ params := &iam.CreateUserInput{UserName: userName}
+ req, _ := iam.New(session.New()).CreateUserRequest(params)
+ _ = req.Build()
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+ })
+
+ // 2. Create access key for user
+ t.Run("CreateAccessKey", func(t *testing.T) {
+ userName := aws.String("WorkflowUser")
+ params := &iam.CreateAccessKeyInput{UserName: userName}
+ req, _ := iam.New(session.New()).CreateAccessKeyRequest(params)
+ _ = req.Build()
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+ })
+
+ // 3. Attach policy to user
+ t.Run("PutUserPolicy", func(t *testing.T) {
+ params := &iam.PutUserPolicyInput{
+ UserName: aws.String("WorkflowUser"),
+ PolicyName: aws.String("ReadWritePolicy"),
+ PolicyDocument: aws.String(`{
+ "Version": "2012-10-17",
+ "Statement": [{
+ "Effect": "Allow",
+ "Action": ["s3:Get*", "s3:Put*"],
+ "Resource": ["arn:aws:s3:::*"]
+ }]
+ }`),
+ }
+ req, _ := iam.New(session.New()).PutUserPolicyRequest(params)
+ _ = req.Build()
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+ })
+
+ // 4. List users to verify
+ t.Run("ListUsers", func(t *testing.T) {
+ params := &iam.ListUsersInput{}
+ req, _ := iam.New(session.New()).ListUsersRequest(params)
+ _ = req.Build()
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+ })
+
+ // 5. Delete user
+ t.Run("DeleteUser", func(t *testing.T) {
+ params := &iam.DeleteUserInput{UserName: aws.String("WorkflowUser")}
+ req, _ := iam.New(session.New()).DeleteUserRequest(params)
+ _ = req.Build()
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+ })
+}
+
+// TestIamStringSlicesEqual tests the iamStringSlicesEqual helper function
+func TestIamStringSlicesEqual(t *testing.T) {
+ tests := []struct {
+ name string
+ a []string
+ b []string
+ expected bool
+ }{
+ {"both empty", []string{}, []string{}, true},
+ {"both nil", nil, nil, true},
+ {"same elements same order", []string{"a", "b", "c"}, []string{"a", "b", "c"}, true},
+ {"same elements different order", []string{"c", "a", "b"}, []string{"a", "b", "c"}, true},
+ {"different lengths", []string{"a", "b"}, []string{"a", "b", "c"}, false},
+ {"different elements", []string{"a", "b", "c"}, []string{"a", "b", "d"}, false},
+ {"one empty one not", []string{}, []string{"a"}, false},
+ {"duplicates same", []string{"a", "a", "b"}, []string{"a", "b", "a"}, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := iamStringSlicesEqual(tt.a, tt.b)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// TestIamHash tests the iamHash function
+func TestIamHash(t *testing.T) {
+ input := "test-policy-document"
+ hash := iamHash(&input)
+
+ // Hash should be non-empty
+ assert.NotEmpty(t, hash)
+
+ // Same input should produce same hash
+ hash2 := iamHash(&input)
+ assert.Equal(t, hash, hash2)
+
+ // Different input should produce different hash
+ input2 := "different-policy"
+ hash3 := iamHash(&input2)
+ assert.NotEqual(t, hash, hash3)
+}
+
+// TestIamStringWithCharset tests the cryptographically secure random string generator
+func TestIamStringWithCharset(t *testing.T) {
+ charset := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ length := 20
+
+ str, err := iamStringWithCharset(length, charset)
+ assert.NoError(t, err)
+ assert.Len(t, str, length)
+
+ // All characters should be from the charset
+ for _, c := range str {
+ assert.Contains(t, charset, string(c))
+ }
+
+ // Two calls should produce different strings (with very high probability)
+ str2, err := iamStringWithCharset(length, charset)
+ assert.NoError(t, err)
+ assert.NotEqual(t, str, str2)
+}
+
+// TestIamMapToStatementAction tests action mapping
+func TestIamMapToStatementAction(t *testing.T) {
+ // iamMapToStatementAction maps IAM statement action patterns to internal action names
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"*", "Admin"},
+ {"Get*", "Read"},
+ {"Put*", "Write"},
+ {"List*", "List"},
+ {"Tagging*", "Tagging"},
+ {"DeleteBucket*", "DeleteBucket"},
+ {"PutBucketAcl", "WriteAcp"},
+ {"GetBucketAcl", "ReadAcp"},
+ {"InvalidAction", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ result := iamMapToStatementAction(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// TestIamMapToIdentitiesAction tests reverse action mapping
+func TestIamMapToIdentitiesAction(t *testing.T) {
+ // iamMapToIdentitiesAction maps internal action names to IAM statement action patterns
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"Admin", "*"},
+ {"Read", "Get*"},
+ {"Write", "Put*"},
+ {"List", "List*"},
+ {"Tagging", "Tagging*"},
+ {"Unknown", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ result := iamMapToIdentitiesAction(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// TestEmbeddedIamGetUserNotFound tests GetUser with non-existent user
+func TestEmbeddedIamGetUserNotFound(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {Name: "ExistingUser"},
+ },
+ }
+
+ userName := aws.String("NonExistentUser")
+ params := &iam.GetUserInput{UserName: userName}
+ req, _ := iam.New(session.New()).GetUserRequest(params)
+ _ = req.Build()
+ response, _ := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.Equal(t, http.StatusNotFound, response.Code)
+}
+
+// TestEmbeddedIamDeleteUserNotFound tests DeleteUser with non-existent user
+func TestEmbeddedIamDeleteUserNotFound(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{}
+
+ userName := aws.String("NonExistentUser")
+ params := &iam.DeleteUserInput{UserName: userName}
+ req, _ := iam.New(session.New()).DeleteUserRequest(params)
+ _ = req.Build()
+ response, _ := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.Equal(t, http.StatusNotFound, response.Code)
+}
+
+// TestEmbeddedIamUpdateUserNotFound tests UpdateUser with non-existent user
+func TestEmbeddedIamUpdateUserNotFound(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{}
+
+ params := &iam.UpdateUserInput{
+ UserName: aws.String("NonExistentUser"),
+ NewUserName: aws.String("NewName"),
+ }
+ req, _ := iam.New(session.New()).UpdateUserRequest(params)
+ _ = req.Build()
+ response, _ := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.Equal(t, http.StatusBadRequest, response.Code)
+}
+
+// TestEmbeddedIamCreateAccessKeyForExistingUser tests CreateAccessKey creates credentials for existing user
+func TestEmbeddedIamCreateAccessKeyForExistingUser(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {Name: "ExistingUser"},
+ },
+ }
+
+ // Use direct form post
+ form := url.Values{}
+ form.Set("Action", "CreateAccessKey")
+ form.Set("UserName", "ExistingUser")
+
+ req, _ := http.NewRequest("POST", "/", nil)
+ req.PostForm = form
+ req.Form = form
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ rr := httptest.NewRecorder()
+ apiRouter := mux.NewRouter().SkipClean(true)
+ apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
+ apiRouter.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusOK, rr.Code)
+ // Verify credentials were created
+ assert.Len(t, api.mockConfig.Identities[0].Credentials, 1)
+}
+
+// TestEmbeddedIamGetUserPolicyUserNotFound tests GetUserPolicy with non-existent user
+func TestEmbeddedIamGetUserPolicyUserNotFound(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{}
+
+ params := &iam.GetUserPolicyInput{
+ UserName: aws.String("NonExistentUser"),
+ PolicyName: aws.String("TestPolicy"),
+ }
+ req, _ := iam.New(session.New()).GetUserPolicyRequest(params)
+ _ = req.Build()
+ response, _ := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.Equal(t, http.StatusNotFound, response.Code)
+}
+
+// TestEmbeddedIamCreatePolicyMalformed tests CreatePolicy with invalid policy document
+func TestEmbeddedIamCreatePolicyMalformed(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+
+ params := &iam.CreatePolicyInput{
+ PolicyName: aws.String("TestPolicy"),
+ PolicyDocument: aws.String("invalid json"),
+ }
+ req, _ := iam.New(session.New()).CreatePolicyRequest(params)
+ _ = req.Build()
+ response, _ := executeEmbeddedIamRequest(api, req.HTTPRequest, nil)
+ assert.Equal(t, http.StatusBadRequest, response.Code)
+}
+
+// TestEmbeddedIamListAccessKeysForUser tests listing access keys for a specific user
+func TestEmbeddedIamListAccessKeysForUser(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "TestUser",
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIATEST1", SecretKey: "secret1"},
+ {AccessKey: "AKIATEST2", SecretKey: "secret2"},
+ },
+ },
+ },
+ }
+
+ params := &iam.ListAccessKeysInput{UserName: aws.String("TestUser")}
+ req, _ := iam.New(session.New()).ListAccessKeysRequest(params)
+ _ = req.Build()
+ out := iamListAccessKeysResponse{}
+ response, err := executeEmbeddedIamRequest(api, req.HTTPRequest, &out)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusOK, response.Code)
+
+ // Verify both access keys are listed
+ assert.Len(t, out.ListAccessKeysResult.AccessKeyMetadata, 2)
+}
+
+// TestEmbeddedIamNotImplementedAction tests handling of unimplemented actions
+func TestEmbeddedIamNotImplementedAction(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+
+ form := url.Values{}
+ form.Set("Action", "SomeUnknownAction")
+
+ req, _ := http.NewRequest("POST", "/", nil)
+ req.PostForm = form
+ req.Form = form
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ rr := httptest.NewRecorder()
+ apiRouter := mux.NewRouter().SkipClean(true)
+ apiRouter.Path("/").Methods(http.MethodPost).HandlerFunc(api.DoActions)
+ apiRouter.ServeHTTP(rr, req)
+
+ assert.Equal(t, http.StatusNotImplemented, rr.Code)
+}
+
+// TestGetPolicyDocument tests parsing of policy documents
+func TestGetPolicyDocument(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+
+ validPolicy := `{
+ "Version": "2012-10-17",
+ "Statement": [{
+ "Effect": "Allow",
+ "Action": ["s3:GetObject"],
+ "Resource": ["arn:aws:s3:::bucket/*"]
+ }]
+ }`
+
+ doc, err := api.GetPolicyDocument(&validPolicy)
+ assert.NoError(t, err)
+ assert.Equal(t, "2012-10-17", doc.Version)
+ assert.Len(t, doc.Statement, 1)
+
+ // Test invalid JSON
+ invalidPolicy := "not valid json"
+ _, err = api.GetPolicyDocument(&invalidPolicy)
+ assert.Error(t, err)
+}
+
+// TestEmbeddedIamGetActionsFromPolicy tests action extraction from policy documents
+func TestEmbeddedIamGetActionsFromPolicy(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+
+ // Actions must use wildcards (Get*, Put*, List*, etc.) as expected by the mapper
+ policyDoc := `{
+ "Version": "2012-10-17",
+ "Statement": [{
+ "Effect": "Allow",
+ "Action": ["s3:Get*", "s3:Put*"],
+ "Resource": ["arn:aws:s3:::mybucket/*"]
+ }]
+ }`
+
+ policy, err := api.GetPolicyDocument(&policyDoc)
+ assert.NoError(t, err)
+
+ actions, err := api.getActions(&policy)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, actions)
+ // Should have Read and Write actions for the bucket
+ // arn:aws:s3:::mybucket/* means all objects in mybucket, represented as "Action:mybucket"
+ assert.Contains(t, actions, "Read:mybucket")
+ assert.Contains(t, actions, "Write:mybucket")
+}
+
diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go
index 4a8368409..bf1a44e54 100644
--- a/weed/s3api/s3api_server.go
+++ b/weed/s3api/s3api_server.go
@@ -50,6 +50,7 @@ type S3ApiServerOption struct {
IamConfig string // Advanced IAM configuration file path
ConcurrentUploadLimit int64
ConcurrentFileUploadLimit int64
+ EnableIam bool // Enable embedded IAM API on the same port
}
type S3ApiServer struct {
@@ -69,6 +70,7 @@ type S3ApiServer struct {
inFlightDataSize int64
inFlightUploads int64
inFlightDataLimitCond *sync.Cond
+ embeddedIam *EmbeddedIamApi // Embedded IAM API server (when enabled)
}
func NewS3ApiServer(router *mux.Router, option *S3ApiServerOption) (s3ApiServer *S3ApiServer, err error) {
@@ -186,6 +188,12 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
}
}
+ // Initialize embedded IAM API if enabled
+ if option.EnableIam {
+ s3ApiServer.embeddedIam = NewEmbeddedIamApi(s3ApiServer.credentialManager, iam)
+ glog.V(0).Infof("Embedded IAM API initialized (use -iam=false to disable)")
+ }
+
if option.Config != "" {
grace.OnReload(func() {
if err := s3ApiServer.iam.loadS3ApiConfigurationFromFile(option.Config); err != nil {
@@ -594,6 +602,16 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
}
})
+ // Embedded IAM API (POST to "/" with Action parameter)
+ // This must be before ListBuckets since IAM uses POST and ListBuckets uses GET
+ // Uses AuthIam for granular permission checking:
+ // - Self-service operations (own access keys) don't require admin
+ // - Operations on other users require admin privileges
+ if s3a.embeddedIam != nil {
+ apiRouter.Methods(http.MethodPost).Path("/").HandlerFunc(track(s3a.embeddedIam.AuthIam(s3a.cb.Limit(s3a.embeddedIam.DoActions, ACTION_WRITE)), "IAM"))
+ glog.V(0).Infof("Embedded IAM API enabled on S3 port")
+ }
+
// ListBuckets
apiRouter.Methods(http.MethodGet).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.ListBucketsHandler, ACTION_LIST), "LIST"))