From e03b2ee8bbeb6e23bf46bcc26cb72ada615a24c2 Mon Sep 17 00:00:00 2001 From: chrislu Date: Sun, 14 Dec 2025 16:31:06 -0800 Subject: feat(iam): add SetUserStatus and UpdateAccessKey actions (#7745) Add ability to enable/disable users and access keys without deleting them. ## Changes ### Protocol Buffer Updates - Add `disabled` field (bool) to Identity message for user status - false (default) = enabled, true = disabled - No backward compatibility hack needed since zero value is correct - Add `status` field (string: Active/Inactive) to Credential message ### New IAM Actions - SetUserStatus: Enable or disable a user (requires admin) - UpdateAccessKey: Change access key status (self-service or admin) ### Behavior - Disabled users: All API requests return AccessDenied - Inactive access keys: Signature validation fails - Status check happens early in auth flow for performance - Backward compatible: existing configs default to enabled (disabled=false) ### Use Cases 1. Temporary suspension: Disable user access during investigation 2. Key rotation: Deactivate old key before deletion 3. Offboarding: Disable rather than delete for audit purposes 4. Emergency response: Quickly disable compromised credentials Fixes #7745 --- weed/s3api/s3api_embedded_iam.go | 105 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 8 deletions(-) (limited to 'weed/s3api/s3api_embedded_iam.go') 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{} -- cgit v1.2.3