diff options
| author | chrislu <chris.lu@gmail.com> | 2025-12-14 16:08:56 -0800 |
|---|---|---|
| committer | chrislu <chris.lu@gmail.com> | 2025-12-14 16:08:56 -0800 |
| commit | f734b2d4bf154b372d382283a8ef09fe1c808154 (patch) | |
| tree | 85d2e06d14257051a3e57da1d6ee773a401113fe /weed/iam | |
| parent | f41925b60bd066048217a6de23185a6e6cfb75a7 (diff) | |
| download | seaweedfs-f734b2d4bf154b372d382283a8ef09fe1c808154.tar.xz seaweedfs-f734b2d4bf154b372d382283a8ef09fe1c808154.zip | |
Refactor: Extract common IAM logic into shared weed/iam package (#7747)
This resolves GitHub issue #7747 by extracting duplicated IAM code into
a shared package that both the embedded S3 IAM and standalone IAM use.
New shared package (weed/iam/):
- constants.go: Common constants (charsets, action strings, error messages)
- helpers.go: Shared helper functions (Hash, GenerateRandomString,
GenerateAccessKeyId, GenerateSecretAccessKey, StringSlicesEqual,
MapToStatementAction, MapToIdentitiesAction, MaskAccessKey)
- responses.go: Common IAM response structs (CommonResponse, ListUsersResponse,
CreateUserResponse, etc.)
- helpers_test.go: Unit tests for shared helpers
Updated files:
- weed/s3api/s3api_embedded_iam.go: Use type aliases and function wrappers
to the shared package, removing ~200 lines of duplicated code
- weed/iamapi/iamapi_management_handlers.go: Use shared package for constants
and helper functions, removing ~100 lines of duplicated code
- weed/iamapi/iamapi_response.go: Re-export types from shared package for
backwards compatibility
Benefits:
- Single source of truth for IAM constants and helpers
- Easier maintenance - changes only need to be made in one place
- Reduced risk of inconsistencies between embedded and standalone IAM
- Better test coverage through shared test suite
Diffstat (limited to 'weed/iam')
| -rw-r--r-- | weed/iam/constants.go | 32 | ||||
| -rw-r--r-- | weed/iam/helpers.go | 126 | ||||
| -rw-r--r-- | weed/iam/helpers_test.go | 135 | ||||
| -rw-r--r-- | weed/iam/responses.go | 140 |
4 files changed, 433 insertions, 0 deletions
diff --git a/weed/iam/constants.go b/weed/iam/constants.go new file mode 100644 index 000000000..0b857a896 --- /dev/null +++ b/weed/iam/constants.go @@ -0,0 +1,32 @@ +package iam + +// Character sets for credential generation +const ( + CharsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + Charset = CharsetUpper + "abcdefghijklmnopqrstuvwxyz/" +) + +// Policy document version +const PolicyDocumentVersion = "2012-10-17" + +// Error message templates +const UserDoesNotExist = "the user with name %s cannot be found." + +// Statement action constants - these map to IAM policy actions +const ( + StatementActionAdmin = "*" + StatementActionWrite = "Put*" + StatementActionWriteAcp = "PutBucketAcl" + StatementActionRead = "Get*" + StatementActionReadAcp = "GetBucketAcl" + StatementActionList = "List*" + StatementActionTagging = "Tagging*" + StatementActionDelete = "DeleteBucket*" +) + +// Access key lengths +const ( + AccessKeyIdLength = 21 + SecretAccessKeyLength = 42 +) + diff --git a/weed/iam/helpers.go b/weed/iam/helpers.go new file mode 100644 index 000000000..02b5fe5b4 --- /dev/null +++ b/weed/iam/helpers.go @@ -0,0 +1,126 @@ +package iam + +import ( + "crypto/rand" + "crypto/sha1" + "fmt" + "math/big" + "sort" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +// Hash computes a SHA1 hash of the input string. +func Hash(s *string) string { + h := sha1.New() + h.Write([]byte(*s)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// GenerateRandomString generates a cryptographically secure random string. +// Uses crypto/rand for security-sensitive credential generation. +func GenerateRandomString(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 +} + +// GenerateAccessKeyId generates a new access key ID. +func GenerateAccessKeyId() (string, error) { + return GenerateRandomString(AccessKeyIdLength, CharsetUpper) +} + +// GenerateSecretAccessKey generates a new secret access key. +func GenerateSecretAccessKey() (string, error) { + return GenerateRandomString(SecretAccessKeyLength, Charset) +} + +// 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 +} + +// MapToStatementAction converts a policy statement action to an S3 action constant. +func MapToStatementAction(action string) string { + switch action { + case StatementActionAdmin: + return s3_constants.ACTION_ADMIN + case StatementActionWrite: + return s3_constants.ACTION_WRITE + case StatementActionWriteAcp: + return s3_constants.ACTION_WRITE_ACP + case StatementActionRead: + return s3_constants.ACTION_READ + case StatementActionReadAcp: + return s3_constants.ACTION_READ_ACP + case StatementActionList: + return s3_constants.ACTION_LIST + case StatementActionTagging: + return s3_constants.ACTION_TAGGING + case StatementActionDelete: + return s3_constants.ACTION_DELETE_BUCKET + default: + return "" + } +} + +// MapToIdentitiesAction converts an S3 action constant to a policy statement action. +func MapToIdentitiesAction(action string) string { + switch action { + case s3_constants.ACTION_ADMIN: + return StatementActionAdmin + case s3_constants.ACTION_WRITE: + return StatementActionWrite + case s3_constants.ACTION_WRITE_ACP: + return StatementActionWriteAcp + case s3_constants.ACTION_READ: + return StatementActionRead + case s3_constants.ACTION_READ_ACP: + return StatementActionReadAcp + case s3_constants.ACTION_LIST: + return StatementActionList + case s3_constants.ACTION_TAGGING: + return StatementActionTagging + case s3_constants.ACTION_DELETE_BUCKET: + return StatementActionDelete + default: + return "" + } +} + +// MaskAccessKey masks an access key for logging, showing only the first 4 characters. +func MaskAccessKey(accessKeyId string) string { + if len(accessKeyId) > 4 { + return accessKeyId[:4] + "***" + } + return accessKeyId +} + diff --git a/weed/iam/helpers_test.go b/weed/iam/helpers_test.go new file mode 100644 index 000000000..c9913d28a --- /dev/null +++ b/weed/iam/helpers_test.go @@ -0,0 +1,135 @@ +package iam + +import ( + "testing" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/stretchr/testify/assert" +) + +func TestHash(t *testing.T) { + input := "test" + result := Hash(&input) + assert.NotEmpty(t, result) + assert.Len(t, result, 40) // SHA1 hex is 40 chars + + // Same input should produce same hash + result2 := Hash(&input) + assert.Equal(t, result, result2) + + // Different input should produce different hash + different := "different" + result3 := Hash(&different) + assert.NotEqual(t, result, result3) +} + +func TestGenerateRandomString(t *testing.T) { + // Valid generation + result, err := GenerateRandomString(10, CharsetUpper) + assert.NoError(t, err) + assert.Len(t, result, 10) + + // Different calls should produce different results (with high probability) + result2, err := GenerateRandomString(10, CharsetUpper) + assert.NoError(t, err) + assert.NotEqual(t, result, result2) + + // Invalid length + _, err = GenerateRandomString(0, CharsetUpper) + assert.Error(t, err) + + _, err = GenerateRandomString(-1, CharsetUpper) + assert.Error(t, err) + + // Empty charset + _, err = GenerateRandomString(10, "") + assert.Error(t, err) +} + +func TestGenerateAccessKeyId(t *testing.T) { + keyId, err := GenerateAccessKeyId() + assert.NoError(t, err) + assert.Len(t, keyId, AccessKeyIdLength) +} + +func TestGenerateSecretAccessKey(t *testing.T) { + secretKey, err := GenerateSecretAccessKey() + assert.NoError(t, err) + assert.Len(t, secretKey, SecretAccessKeyLength) +} + +func TestStringSlicesEqual(t *testing.T) { + tests := []struct { + a []string + b []string + expected bool + }{ + {[]string{"a", "b", "c"}, []string{"a", "b", "c"}, true}, + {[]string{"c", "b", "a"}, []string{"a", "b", "c"}, true}, // Order independent + {[]string{"a", "b"}, []string{"a", "b", "c"}, false}, + {[]string{}, []string{}, true}, + {nil, nil, true}, + {[]string{"a"}, []string{"b"}, false}, + } + + for _, test := range tests { + result := StringSlicesEqual(test.a, test.b) + assert.Equal(t, test.expected, result) + } +} + +func TestMapToStatementAction(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {StatementActionAdmin, s3_constants.ACTION_ADMIN}, + {StatementActionWrite, s3_constants.ACTION_WRITE}, + {StatementActionRead, s3_constants.ACTION_READ}, + {StatementActionList, s3_constants.ACTION_LIST}, + {StatementActionDelete, s3_constants.ACTION_DELETE_BUCKET}, + {"unknown", ""}, + } + + for _, test := range tests { + result := MapToStatementAction(test.input) + assert.Equal(t, test.expected, result) + } +} + +func TestMapToIdentitiesAction(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {s3_constants.ACTION_ADMIN, StatementActionAdmin}, + {s3_constants.ACTION_WRITE, StatementActionWrite}, + {s3_constants.ACTION_READ, StatementActionRead}, + {s3_constants.ACTION_LIST, StatementActionList}, + {s3_constants.ACTION_DELETE_BUCKET, StatementActionDelete}, + {"unknown", ""}, + } + + for _, test := range tests { + result := MapToIdentitiesAction(test.input) + assert.Equal(t, test.expected, result) + } +} + +func TestMaskAccessKey(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"AKIAIOSFODNN7EXAMPLE", "AKIA***"}, + {"AKIA", "AKIA"}, + {"AKI", "AKI"}, + {"", ""}, + } + + for _, test := range tests { + result := MaskAccessKey(test.input) + assert.Equal(t, test.expected, result) + } +} + diff --git a/weed/iam/responses.go b/weed/iam/responses.go new file mode 100644 index 000000000..a45c9fd16 --- /dev/null +++ b/weed/iam/responses.go @@ -0,0 +1,140 @@ +package iam + +import ( + "encoding/xml" + "fmt" + "time" + + "github.com/aws/aws-sdk-go/service/iam" +) + +// CommonResponse is embedded in all IAM response types to provide RequestId. +type CommonResponse struct { + ResponseMetadata struct { + RequestId string `xml:"RequestId"` + } `xml:"ResponseMetadata"` +} + +// SetRequestId sets a unique request ID based on current timestamp. +func (r *CommonResponse) SetRequestId() { + r.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano()) +} + +// ListUsersResponse is the response for ListUsers action. +type ListUsersResponse struct { + CommonResponse + 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"` +} + +// ListAccessKeysResponse is the response for ListAccessKeys action. +type ListAccessKeysResponse struct { + CommonResponse + 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"` +} + +// DeleteAccessKeyResponse is the response for DeleteAccessKey action. +type DeleteAccessKeyResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteAccessKeyResponse"` +} + +// CreatePolicyResponse is the response for CreatePolicy action. +type CreatePolicyResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreatePolicyResponse"` + CreatePolicyResult struct { + Policy iam.Policy `xml:"Policy"` + } `xml:"CreatePolicyResult"` +} + +// CreateUserResponse is the response for CreateUser action. +type CreateUserResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateUserResponse"` + CreateUserResult struct { + User iam.User `xml:"User"` + } `xml:"CreateUserResult"` +} + +// DeleteUserResponse is the response for DeleteUser action. +type DeleteUserResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserResponse"` +} + +// GetUserResponse is the response for GetUser action. +type GetUserResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetUserResponse"` + GetUserResult struct { + User iam.User `xml:"User"` + } `xml:"GetUserResult"` +} + +// UpdateUserResponse is the response for UpdateUser action. +type UpdateUserResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateUserResponse"` +} + +// CreateAccessKeyResponse is the response for CreateAccessKey action. +type CreateAccessKeyResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateAccessKeyResponse"` + CreateAccessKeyResult struct { + AccessKey iam.AccessKey `xml:"AccessKey"` + } `xml:"CreateAccessKeyResult"` +} + +// PutUserPolicyResponse is the response for PutUserPolicy action. +type PutUserPolicyResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ PutUserPolicyResponse"` +} + +// DeleteUserPolicyResponse is the response for DeleteUserPolicy action. +type DeleteUserPolicyResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteUserPolicyResponse"` +} + +// GetUserPolicyResponse is the response for GetUserPolicy action. +type GetUserPolicyResponse struct { + CommonResponse + 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"` +} + +// ErrorResponse is the IAM error response format. +type ErrorResponse struct { + CommonResponse + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ErrorResponse"` + Error struct { + iam.ErrorDetails + Type string `xml:"Type"` + } `xml:"Error"` +} + +// Error represents an IAM API error with code and underlying error. +type Error struct { + Code string + Error error +} + +// Policies stores IAM policies (used for managed policy storage). +type Policies struct { + Policies map[string]interface{} `json:"policies"` +} + |
