aboutsummaryrefslogtreecommitdiff
path: root/weed/s3api
diff options
context:
space:
mode:
Diffstat (limited to 'weed/s3api')
-rw-r--r--weed/s3api/auth_credentials.go17
-rw-r--r--weed/s3api/auto_signature_v4_test.go4
-rw-r--r--weed/s3api/s3api_bucket_handlers_test.go6
-rw-r--r--weed/s3api/s3api_embedded_iam.go105
-rw-r--r--weed/s3api/s3api_embedded_iam_test.go416
5 files changed, 534 insertions, 14 deletions
diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go
index c81fb3a88..5acd711cd 100644
--- a/weed/s3api/auth_credentials.go
+++ b/weed/s3api/auth_credentials.go
@@ -66,6 +66,7 @@ type Identity struct {
Credentials []*Credential
Actions []Action
PrincipalArn string // ARN for IAM authorization (e.g., "arn:aws:iam::account-id:user/username")
+ Disabled bool // User status: false = enabled (default), true = disabled
}
// Account represents a system user, a system user can
@@ -101,6 +102,7 @@ var (
type Credential struct {
AccessKey string
SecretKey string
+ Status string // Access key status: "Active" or "Inactive" (empty treated as "Active")
}
// "Permission": "FULL_CONTROL"|"WRITE"|"WRITE_ACP"|"READ"|"READ_ACP"
@@ -318,12 +320,13 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
emailAccount[AccountAnonymous.EmailAddress] = &AccountAnonymous
}
for _, ident := range config.Identities {
- glog.V(3).Infof("loading identity %s", ident.Name)
+ glog.V(3).Infof("loading identity %s (disabled=%v)", ident.Name, ident.Disabled)
t := &Identity{
Name: ident.Name,
Credentials: nil,
Actions: nil,
PrincipalArn: generatePrincipalArn(ident.Name),
+ Disabled: ident.Disabled, // false (default) = enabled, true = disabled
}
switch {
case ident.Name == AccountAnonymous.Id:
@@ -347,6 +350,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api
t.Credentials = append(t.Credentials, &Credential{
AccessKey: cred.AccessKey,
SecretKey: cred.SecretKey,
+ Status: cred.Status, // Load access key status
})
accessKeyIdent[cred.AccessKey] = t
}
@@ -405,8 +409,19 @@ func (iam *IdentityAccessManagement) lookupByAccessKey(accessKey string) (identi
truncatedKey, len(accessKey), len(iam.accessKeyIdent))
if ident, ok := iam.accessKeyIdent[accessKey]; ok {
+ // Check if user is disabled
+ if ident.Disabled {
+ glog.V(2).Infof("User %s is disabled, rejecting access key %s", ident.Name, truncatedKey)
+ return nil, nil, false
+ }
+
for _, credential := range ident.Credentials {
if credential.AccessKey == accessKey {
+ // Check if access key is inactive (empty Status treated as Active for backward compatibility)
+ if credential.Status == iamAccessKeyStatusInactive {
+ glog.V(2).Infof("Access key %s for identity %s is inactive", truncatedKey, ident.Name)
+ return nil, nil, false
+ }
glog.V(2).Infof("Found access key %s for identity %s", truncatedKey, ident.Name)
return ident, credential, true
}
diff --git a/weed/s3api/auto_signature_v4_test.go b/weed/s3api/auto_signature_v4_test.go
index b23756f33..7079273ee 100644
--- a/weed/s3api/auto_signature_v4_test.go
+++ b/weed/s3api/auto_signature_v4_test.go
@@ -190,7 +190,7 @@ func mustNewRequest(method string, urlStr string, contentLength int64, body io.R
// is signed with AWS Signature V4, fails if not able to do so.
func mustNewSignedRequest(method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request {
req := mustNewRequest(method, urlStr, contentLength, body, t)
- cred := &Credential{"access_key_1", "secret_key_1"}
+ cred := &Credential{AccessKey: "access_key_1", SecretKey: "secret_key_1"}
if err := signRequestV4(req, cred.AccessKey, cred.SecretKey); err != nil {
t.Fatalf("Unable to initialized new signed http request %s", err)
}
@@ -201,7 +201,7 @@ func mustNewSignedRequest(method string, urlStr string, contentLength int64, bod
// is presigned with AWS Signature V4, fails if not able to do so.
func mustNewPresignedRequest(iam *IdentityAccessManagement, method string, urlStr string, contentLength int64, body io.ReadSeeker, t *testing.T) *http.Request {
req := mustNewRequest(method, urlStr, contentLength, body, t)
- cred := &Credential{"access_key_1", "secret_key_1"}
+ cred := &Credential{AccessKey: "access_key_1", SecretKey: "secret_key_1"}
if err := preSignV4(iam, req, cred.AccessKey, cred.SecretKey, int64(10*time.Minute.Seconds())); err != nil {
t.Fatalf("Unable to initialized new signed http request %s", err)
}
diff --git a/weed/s3api/s3api_bucket_handlers_test.go b/weed/s3api/s3api_bucket_handlers_test.go
index 40357a2b7..c2870b15e 100644
--- a/weed/s3api/s3api_bucket_handlers_test.go
+++ b/weed/s3api/s3api_bucket_handlers_test.go
@@ -670,7 +670,7 @@ func TestListBucketsIssue7647(t *testing.T) {
t.Run("admin user can see their created buckets", func(t *testing.T) {
// Simulate the exact scenario from issue #7647:
// User "root" with ["Admin", "Read", "Write", "Tagging", "List"] permissions
-
+
// Create identity for root user with Admin action
rootIdentity := &Identity{
Name: "root",
@@ -730,7 +730,7 @@ func TestListBucketsIssue7647(t *testing.T) {
t.Run("admin user sees buckets without owner metadata", func(t *testing.T) {
// Admin users should see buckets even if they don't have owner metadata
// (this can happen with legacy buckets or manual creation)
-
+
rootIdentity := &Identity{
Name: "root",
Actions: []Action{
@@ -754,7 +754,7 @@ func TestListBucketsIssue7647(t *testing.T) {
t.Run("non-admin user cannot see buckets without owner", func(t *testing.T) {
// Non-admin users should not see buckets without owner metadata
-
+
regularUser := &Identity{
Name: "user1",
Actions: []Action{
diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go
index 5dce8a3e0..11c03386f 100644
--- a/weed/s3api/s3api_embedded_iam.go
+++ b/weed/s3api/s3api_embedded_iam.go
@@ -56,6 +56,8 @@ type (
iamPutUserPolicyResponse = iamlib.PutUserPolicyResponse
iamDeleteUserPolicyResponse = iamlib.DeleteUserPolicyResponse
iamGetUserPolicyResponse = iamlib.GetUserPolicyResponse
+ iamSetUserStatusResponse = iamlib.SetUserStatusResponse
+ iamUpdateAccessKeyResponse = iamlib.UpdateAccessKeyResponse
iamErrorResponse = iamlib.ErrorResponse
iamError = iamlib.Error
)
@@ -83,10 +85,12 @@ func iamMapToIdentitiesAction(action string) string {
// Constants from shared package
const (
- iamCharsetUpper = iamlib.CharsetUpper
- iamCharset = iamlib.Charset
- iamPolicyDocumentVersion = iamlib.PolicyDocumentVersion
- iamUserDoesNotExist = iamlib.UserDoesNotExist
+ iamCharsetUpper = iamlib.CharsetUpper
+ iamCharset = iamlib.Charset
+ iamPolicyDocumentVersion = iamlib.PolicyDocumentVersion
+ iamUserDoesNotExist = iamlib.UserDoesNotExist
+ iamAccessKeyStatusActive = iamlib.AccessKeyStatusActive
+ iamAccessKeyStatusInactive = iamlib.AccessKeyStatusInactive
)
func newIamErrorResponse(errCode string, errMsg string) iamErrorResponse {
@@ -151,15 +155,23 @@ func (e *EmbeddedIamApi) ListUsers(s3cfg *iam_pb.S3ApiConfiguration, values url.
// 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 {
+ // Return actual status from credential, default to Active if not set
+ status := cred.Status
+ if status == "" {
+ status = iamAccessKeyStatusActive
+ }
+ // Capture copies to avoid loop variable pointer aliasing
+ identName := ident.Name
+ accessKey := cred.AccessKey
+ statusCopy := status
resp.ListAccessKeysResult.AccessKeyMetadata = append(resp.ListAccessKeysResult.AccessKeyMetadata,
- &iam.AccessKeyMetadata{UserName: &ident.Name, AccessKeyId: &cred.AccessKey, Status: &status},
+ &iam.AccessKeyMetadata{UserName: &identName, AccessKeyId: &accessKey, Status: &statusCopy},
)
}
}
@@ -184,7 +196,7 @@ func (e *EmbeddedIamApi) CreateUser(s3cfg *iam_pb.S3ApiConfiguration, values url
}
resp.CreateUserResult.User.UserName = &userName
- s3cfg.Identities = append(s3cfg.Identities, &iam_pb.Identity{Name: userName})
+ s3cfg.Identities = append(s3cfg.Identities, &iam_pb.Identity{Name: userName}) // Disabled defaults to false (enabled)
return resp, nil
}
@@ -253,7 +265,7 @@ func (e *EmbeddedIamApi) CreateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, value
for _, ident := range s3cfg.Identities {
if userName == ident.Name {
ident.Credentials = append(ident.Credentials,
- &iam_pb.Credential{AccessKey: accessKeyId, SecretKey: secretAccessKey})
+ &iam_pb.Credential{AccessKey: accessKeyId, SecretKey: secretAccessKey, Status: iamAccessKeyStatusActive})
return resp, nil
}
}
@@ -477,6 +489,70 @@ func (e *EmbeddedIamApi) DeleteUserPolicy(s3cfg *iam_pb.S3ApiConfiguration, valu
return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
}
+// SetUserStatus enables or disables a user without deleting them.
+// This is a SeaweedFS extension for temporary user suspension, offboarding, etc.
+// When a user is disabled, all API requests using their credentials will return AccessDenied.
+func (e *EmbeddedIamApi) SetUserStatus(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamSetUserStatusResponse, *iamError) {
+ var resp iamSetUserStatusResponse
+ userName := values.Get("UserName")
+ status := values.Get("Status")
+
+ // Validate UserName
+ if userName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")}
+ }
+
+ // Validate Status - must be "Active" or "Inactive"
+ if status != iamAccessKeyStatusActive && status != iamAccessKeyStatusInactive {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("Status must be '%s' or '%s'", iamAccessKeyStatusActive, iamAccessKeyStatusInactive)}
+ }
+
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == userName {
+ // Set disabled based on status: Active = not disabled, Inactive = disabled
+ ident.Disabled = (status == iamAccessKeyStatusInactive)
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
+}
+
+// UpdateAccessKey updates the status of an access key (Active or Inactive).
+// This allows key rotation workflows where old keys are deactivated before deletion.
+func (e *EmbeddedIamApi) UpdateAccessKey(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (iamUpdateAccessKeyResponse, *iamError) {
+ var resp iamUpdateAccessKeyResponse
+ userName := values.Get("UserName")
+ accessKeyId := values.Get("AccessKeyId")
+ status := values.Get("Status")
+
+ // Validate required parameters
+ if userName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")}
+ }
+ if accessKeyId == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("AccessKeyId is required")}
+ }
+ if status != iamAccessKeyStatusActive && status != iamAccessKeyStatusInactive {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("Status must be '%s' or '%s'", iamAccessKeyStatusActive, iamAccessKeyStatusInactive)}
+ }
+
+ for _, ident := range s3cfg.Identities {
+ if ident.Name != userName {
+ continue
+ }
+ for _, cred := range ident.Credentials {
+ if cred.AccessKey == accessKeyId {
+ cred.Status = status
+ return resp, nil
+ }
+ }
+ // User found but access key not found
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("the access key with id %s cannot be found", accessKeyId)}
+ }
+
+ 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."
@@ -707,6 +783,19 @@ func (e *EmbeddedIamApi) DoActions(w http.ResponseWriter, r *http.Request) {
e.writeIamErrorResponse(w, r, iamErr)
return
}
+ case "SetUserStatus":
+ response, iamErr = e.SetUserStatus(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ case "UpdateAccessKey":
+ e.handleImplicitUsername(r, values)
+ response, iamErr = e.UpdateAccessKey(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
default:
errNotImplemented := s3err.GetAPIError(s3err.ErrNotImplemented)
errorResponse := iamErrorResponse{}
diff --git a/weed/s3api/s3api_embedded_iam_test.go b/weed/s3api/s3api_embedded_iam_test.go
index 81839084b..1b44df64c 100644
--- a/weed/s3api/s3api_embedded_iam_test.go
+++ b/weed/s3api/s3api_embedded_iam_test.go
@@ -137,6 +137,19 @@ func (e *EmbeddedIamApiForTest) DoActions(w http.ResponseWriter, r *http.Request
e.writeIamErrorResponse(w, r, iamErr)
return
}
+ case "SetUserStatus":
+ response, iamErr = e.SetUserStatus(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
+ case "UpdateAccessKey":
+ e.handleImplicitUsername(r, values)
+ response, iamErr = e.UpdateAccessKey(s3cfg, values)
+ if iamErr != nil {
+ e.writeIamErrorResponse(w, r, iamErr)
+ return
+ }
default:
http.Error(w, "Not implemented", http.StatusNotImplemented)
return
@@ -1026,3 +1039,406 @@ func TestEmbeddedIamGetActionsFromPolicy(t *testing.T) {
assert.Contains(t, actions, "Write:mybucket")
}
+// TestEmbeddedIamSetUserStatus tests enabling/disabling a user
+func TestEmbeddedIamSetUserStatus(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {Name: "TestUser", Disabled: false},
+ },
+ }
+
+ t.Run("DisableUser", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "SetUserStatus")
+ form.Set("UserName", "TestUser")
+ form.Set("Status", "Inactive")
+
+ 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 user is now disabled
+ assert.True(t, api.mockConfig.Identities[0].Disabled)
+ })
+
+ t.Run("EnableUser", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "SetUserStatus")
+ form.Set("UserName", "TestUser")
+ form.Set("Status", "Active")
+
+ 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 user is now enabled
+ assert.False(t, api.mockConfig.Identities[0].Disabled)
+ })
+}
+
+// TestEmbeddedIamSetUserStatusErrors tests error handling for SetUserStatus
+func TestEmbeddedIamSetUserStatusErrors(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {Name: "TestUser"},
+ },
+ }
+
+ t.Run("UserNotFound", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "SetUserStatus")
+ form.Set("UserName", "NonExistentUser")
+ form.Set("Status", "Inactive")
+
+ 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)
+ })
+
+ t.Run("InvalidStatus", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "SetUserStatus")
+ form.Set("UserName", "TestUser")
+ form.Set("Status", "InvalidStatus")
+
+ 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.StatusBadRequest, rr.Code)
+ })
+
+ t.Run("MissingUserName", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "SetUserStatus")
+ form.Set("Status", "Inactive")
+
+ 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.StatusBadRequest, rr.Code)
+ })
+}
+
+// TestEmbeddedIamUpdateAccessKey tests updating access key status
+func TestEmbeddedIamUpdateAccessKey(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "TestUser",
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIATEST12345", SecretKey: "secret", Status: "Active"},
+ },
+ },
+ },
+ }
+
+ t.Run("DeactivateAccessKey", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "UpdateAccessKey")
+ form.Set("UserName", "TestUser")
+ form.Set("AccessKeyId", "AKIATEST12345")
+ form.Set("Status", "Inactive")
+
+ 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 access key is now inactive
+ assert.Equal(t, "Inactive", api.mockConfig.Identities[0].Credentials[0].Status)
+ })
+
+ t.Run("ActivateAccessKey", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "UpdateAccessKey")
+ form.Set("UserName", "TestUser")
+ form.Set("AccessKeyId", "AKIATEST12345")
+ form.Set("Status", "Active")
+
+ 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 access key is now active
+ assert.Equal(t, "Active", api.mockConfig.Identities[0].Credentials[0].Status)
+ })
+}
+
+// TestEmbeddedIamUpdateAccessKeyErrors tests error handling for UpdateAccessKey
+func TestEmbeddedIamUpdateAccessKeyErrors(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "TestUser",
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIATEST12345", SecretKey: "secret"},
+ },
+ },
+ },
+ }
+
+ t.Run("AccessKeyNotFound", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "UpdateAccessKey")
+ form.Set("UserName", "TestUser")
+ form.Set("AccessKeyId", "NONEXISTENT123")
+ form.Set("Status", "Inactive")
+
+ 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)
+ })
+
+ t.Run("InvalidStatus", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "UpdateAccessKey")
+ form.Set("UserName", "TestUser")
+ form.Set("AccessKeyId", "AKIATEST12345")
+ form.Set("Status", "InvalidStatus")
+
+ 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.StatusBadRequest, rr.Code)
+ })
+
+ t.Run("MissingUserName", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "UpdateAccessKey")
+ form.Set("AccessKeyId", "AKIATEST12345")
+ form.Set("Status", "Inactive")
+
+ 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.StatusBadRequest, rr.Code)
+ })
+
+ t.Run("MissingAccessKeyId", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "UpdateAccessKey")
+ form.Set("UserName", "TestUser")
+ form.Set("Status", "Inactive")
+
+ 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.StatusBadRequest, rr.Code)
+ })
+
+ t.Run("UserNotFound", func(t *testing.T) {
+ form := url.Values{}
+ form.Set("Action", "UpdateAccessKey")
+ form.Set("UserName", "NonExistentUser")
+ form.Set("AccessKeyId", "AKIATEST12345")
+ form.Set("Status", "Inactive")
+
+ 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)
+ })
+}
+
+// TestEmbeddedIamListAccessKeysShowsStatus tests that ListAccessKeys returns the access key status
+func TestEmbeddedIamListAccessKeysShowsStatus(t *testing.T) {
+ api := NewEmbeddedIamApiForTest()
+ api.mockConfig = &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "TestUser",
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIAACTIVE123", SecretKey: "secret1", Status: "Active"},
+ {AccessKey: "AKIAINACTIVE1", SecretKey: "secret2", Status: "Inactive"},
+ {AccessKey: "AKIADEFAULT12", SecretKey: "secret3"}, // No status set, should default to Active
+ },
+ },
+ },
+ }
+
+ 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 all three access keys are listed with correct status
+ assert.Len(t, out.ListAccessKeysResult.AccessKeyMetadata, 3)
+
+ // Find each key and verify status
+ statusMap := make(map[string]string)
+ for _, meta := range out.ListAccessKeysResult.AccessKeyMetadata {
+ statusMap[*meta.AccessKeyId] = *meta.Status
+ }
+
+ assert.Equal(t, "Active", statusMap["AKIAACTIVE123"])
+ assert.Equal(t, "Inactive", statusMap["AKIAINACTIVE1"])
+ assert.Equal(t, "Active", statusMap["AKIADEFAULT12"]) // Default to Active
+}
+
+// TestDisabledUserLookupFails tests that disabled users cannot authenticate
+func TestDisabledUserLookupFails(t *testing.T) {
+ iam := &IdentityAccessManagement{}
+ testConfig := &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "enabledUser",
+ Disabled: false,
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIAENABLED123", SecretKey: "secret1"},
+ },
+ },
+ {
+ Name: "disabledUser",
+ Disabled: true,
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIADISABLED12", SecretKey: "secret2"},
+ },
+ },
+ },
+ }
+ err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig))
+ assert.NoError(t, err)
+
+ // Enabled user should be found
+ identity, cred, found := iam.LookupByAccessKey("AKIAENABLED123")
+ assert.True(t, found)
+ assert.NotNil(t, identity)
+ assert.NotNil(t, cred)
+ assert.Equal(t, "enabledUser", identity.Name)
+
+ // Disabled user should NOT be found
+ identity, cred, found = iam.LookupByAccessKey("AKIADISABLED12")
+ assert.False(t, found)
+ assert.Nil(t, identity)
+ assert.Nil(t, cred)
+}
+
+// TestInactiveAccessKeyLookupFails tests that inactive access keys cannot authenticate
+func TestInactiveAccessKeyLookupFails(t *testing.T) {
+ iam := &IdentityAccessManagement{}
+ testConfig := &iam_pb.S3ApiConfiguration{
+ Identities: []*iam_pb.Identity{
+ {
+ Name: "testUser",
+ Credentials: []*iam_pb.Credential{
+ {AccessKey: "AKIAACTIVE123", SecretKey: "secret1", Status: "Active"},
+ {AccessKey: "AKIAINACTIVE1", SecretKey: "secret2", Status: "Inactive"},
+ {AccessKey: "AKIADEFAULT12", SecretKey: "secret3"}, // No status = Active
+ },
+ },
+ },
+ }
+ err := iam.LoadS3ApiConfigurationFromBytes(mustMarshalJSON(testConfig))
+ assert.NoError(t, err)
+
+ // Active key should be found
+ identity, cred, found := iam.LookupByAccessKey("AKIAACTIVE123")
+ assert.True(t, found)
+ assert.NotNil(t, identity)
+ assert.NotNil(t, cred)
+
+ // Inactive key should NOT be found
+ identity, cred, found = iam.LookupByAccessKey("AKIAINACTIVE1")
+ assert.False(t, found)
+ assert.Nil(t, identity)
+ assert.Nil(t, cred)
+
+ // Key with no status (default Active) should be found
+ identity, cred, found = iam.LookupByAccessKey("AKIADEFAULT12")
+ assert.True(t, found)
+ assert.NotNil(t, identity)
+ assert.NotNil(t, cred)
+}
+