diff --git a/.github/workflows/s3-iam-tests.yml b/.github/workflows/s3-iam-tests.yml
index 841d32514..80010b782 100644
--- a/.github/workflows/s3-iam-tests.yml
+++ b/.github/workflows/s3-iam-tests.yml
@@ -5,6 +5,8 @@ on:
paths:
- 'weed/iam/**'
- 'weed/s3api/**'
+ - 'weed/credential/**'
+ - 'weed/pb/**'
- 'test/s3/iam/**'
- '.github/workflows/s3-iam-tests.yml'
push:
@@ -12,6 +14,8 @@ on:
paths:
- 'weed/iam/**'
- 'weed/s3api/**'
+ - 'weed/credential/**'
+ - 'weed/pb/**'
- 'test/s3/iam/**'
- '.github/workflows/s3-iam-tests.yml'
@@ -80,7 +84,7 @@ jobs:
timeout-minutes: 25
strategy:
matrix:
- test-type: ["basic", "advanced", "policy-enforcement"]
+ test-type: ["basic", "advanced", "policy-enforcement", "group"]
steps:
- name: Check out code
@@ -117,7 +121,7 @@ jobs:
"basic")
echo "Running basic IAM functionality tests..."
make clean setup start-services wait-for-services
- go test -v -timeout 15m -run "TestS3IAMAuthentication|TestS3IAMBasicWorkflow|TestS3IAMTokenValidation|TestIAM" ./...
+ go test -v -timeout 15m -run "TestS3IAMAuthentication|TestS3IAMBasicWorkflow|TestS3IAMTokenValidation|TestIAMUserManagement|TestIAMAccessKeyManagement|TestIAMPolicyManagement" ./...
;;
"advanced")
echo "Running advanced IAM feature tests..."
@@ -129,6 +133,11 @@ jobs:
make clean setup start-services wait-for-services
go test -v -timeout 15m -run "TestS3IAMPolicyEnforcement|TestS3IAMBucketPolicy|TestS3IAMContextual" ./...
;;
+ "group")
+ echo "Running IAM group management tests..."
+ make clean setup start-services wait-for-services
+ go test -v -timeout 15m -run "TestIAMGroup" ./...
+ ;;
*)
echo "Unknown test type: ${{ matrix.test-type }}"
exit 1
diff --git a/test/s3/iam/Makefile b/test/s3/iam/Makefile
index 6eb5b0db8..6dbb54299 100644
--- a/test/s3/iam/Makefile
+++ b/test/s3/iam/Makefile
@@ -185,6 +185,9 @@ test-context: ## Test only contextual policy enforcement
test-presigned: ## Test only presigned URL integration
go test -v -run TestS3IAMPresignedURLIntegration ./...
+test-group: ## Run IAM group management tests
+ go test -v -run "TestIAMGroup" ./...
+
test-sts: ## Run all STS tests
go test -v -run "TestSTS" ./...
@@ -263,7 +266,7 @@ docker-build: ## Build custom SeaweedFS image for Docker tests
# All PHONY targets
.PHONY: test test-quick run-tests setup start-services stop-services wait-for-services clean logs status debug
-.PHONY: test-auth test-policy test-expiration test-multipart test-bucket-policy test-context test-presigned test-sts test-sts-assume-role test-sts-ldap
+.PHONY: test-auth test-policy test-expiration test-multipart test-bucket-policy test-context test-presigned test-group test-sts test-sts-assume-role test-sts-ldap
.PHONY: benchmark ci watch install-deps docker-test docker-up docker-down docker-logs docker-build
.PHONY: test-distributed test-performance test-stress test-versioning-stress test-keycloak-full test-all-previously-skipped setup-all-tests help-advanced
diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go
new file mode 100644
index 000000000..1043e7c95
--- /dev/null
+++ b/test/s3/iam/s3_iam_group_test.go
@@ -0,0 +1,792 @@
+package iam
+
+import (
+ "encoding/xml"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/aws/awserr"
+ "github.com/aws/aws-sdk-go/aws/credentials"
+ "github.com/aws/aws-sdk-go/aws/session"
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/aws/aws-sdk-go/service/s3"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestIAMGroupLifecycle tests the full lifecycle of group management:
+// CreateGroup, GetGroup, ListGroups, DeleteGroup
+func TestIAMGroupLifecycle(t *testing.T) {
+ framework := NewS3IAMTestFramework(t)
+ defer framework.Cleanup()
+
+ iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
+ require.NoError(t, err)
+
+ groupName := "test-group-lifecycle"
+
+ t.Run("create_group", func(t *testing.T) {
+ resp, err := iamClient.CreateGroup(&iam.CreateGroupInput{
+ GroupName: aws.String(groupName),
+ })
+ require.NoError(t, err)
+ assert.Equal(t, groupName, *resp.Group.GroupName)
+ })
+
+ t.Run("get_group", func(t *testing.T) {
+ resp, err := iamClient.GetGroup(&iam.GetGroupInput{
+ GroupName: aws.String(groupName),
+ })
+ require.NoError(t, err)
+ assert.Equal(t, groupName, *resp.Group.GroupName)
+ })
+
+ t.Run("list_groups_contains_created", func(t *testing.T) {
+ resp, err := iamClient.ListGroups(&iam.ListGroupsInput{})
+ require.NoError(t, err)
+ found := false
+ for _, g := range resp.Groups {
+ if *g.GroupName == groupName {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "Created group should appear in ListGroups")
+ })
+
+ t.Run("create_duplicate_group_fails", func(t *testing.T) {
+ _, err := iamClient.CreateGroup(&iam.CreateGroupInput{
+ GroupName: aws.String(groupName),
+ })
+ assert.Error(t, err, "Creating a duplicate group should fail")
+ })
+
+ t.Run("delete_group", func(t *testing.T) {
+ _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{
+ GroupName: aws.String(groupName),
+ })
+ require.NoError(t, err)
+
+ // Verify it's gone
+ resp, err := iamClient.ListGroups(&iam.ListGroupsInput{})
+ require.NoError(t, err)
+ for _, g := range resp.Groups {
+ assert.NotEqual(t, groupName, *g.GroupName,
+ "Deleted group should not appear in ListGroups")
+ }
+ })
+
+ t.Run("delete_nonexistent_group_fails", func(t *testing.T) {
+ _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{
+ GroupName: aws.String("nonexistent-group-xyz"),
+ })
+ assert.Error(t, err)
+ })
+}
+
+// TestIAMGroupMembership tests adding and removing users from groups
+func TestIAMGroupMembership(t *testing.T) {
+ framework := NewS3IAMTestFramework(t)
+ defer framework.Cleanup()
+
+ iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
+ require.NoError(t, err)
+
+ groupName := "test-group-members"
+ userName := "test-user-for-group"
+
+ // Setup: create group and user
+ _, err = iamClient.CreateGroup(&iam.CreateGroupInput{
+ GroupName: aws.String(groupName),
+ })
+ require.NoError(t, err)
+ defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)})
+
+ _, err = iamClient.CreateUser(&iam.CreateUserInput{
+ UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+ defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
+
+ t.Run("add_user_to_group", func(t *testing.T) {
+ _, err := iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
+ GroupName: aws.String(groupName),
+ UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+ })
+
+ t.Run("get_group_shows_member", func(t *testing.T) {
+ resp, err := iamClient.GetGroup(&iam.GetGroupInput{
+ GroupName: aws.String(groupName),
+ })
+ require.NoError(t, err)
+ found := false
+ for _, u := range resp.Users {
+ if *u.UserName == userName {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "Added user should appear in GetGroup members")
+ })
+
+ t.Run("list_groups_for_user", func(t *testing.T) {
+ resp, err := iamClient.ListGroupsForUser(&iam.ListGroupsForUserInput{
+ UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+ found := false
+ for _, g := range resp.Groups {
+ if *g.GroupName == groupName {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "Group should appear in ListGroupsForUser")
+ })
+
+ t.Run("add_duplicate_member_is_idempotent", func(t *testing.T) {
+ _, err := iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
+ GroupName: aws.String(groupName),
+ UserName: aws.String(userName),
+ })
+ // Should succeed (idempotent) or return a benign error
+ // AWS IAM allows duplicate add without error
+ assert.NoError(t, err)
+ })
+
+ t.Run("remove_user_from_group", func(t *testing.T) {
+ _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
+ GroupName: aws.String(groupName),
+ UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+
+ // Verify removal
+ resp, err := iamClient.GetGroup(&iam.GetGroupInput{
+ GroupName: aws.String(groupName),
+ })
+ require.NoError(t, err)
+ for _, u := range resp.Users {
+ assert.NotEqual(t, userName, *u.UserName,
+ "Removed user should not appear in group members")
+ }
+ })
+}
+
+// TestIAMGroupPolicyAttachment tests attaching and detaching policies from groups
+func TestIAMGroupPolicyAttachment(t *testing.T) {
+ framework := NewS3IAMTestFramework(t)
+ defer framework.Cleanup()
+
+ iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
+ require.NoError(t, err)
+
+ groupName := "test-group-policies"
+ policyName := "test-group-attach-policy"
+ policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}`
+
+ // Setup: create group and policy
+ _, err = iamClient.CreateGroup(&iam.CreateGroupInput{
+ GroupName: aws.String(groupName),
+ })
+ require.NoError(t, err)
+
+ createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
+ PolicyName: aws.String(policyName),
+ PolicyDocument: aws.String(policyDoc),
+ })
+ require.NoError(t, err)
+ policyArn := createPolicyResp.Policy.Arn
+
+ // Cleanup in correct order: detach policy, delete group, delete policy
+ t.Cleanup(func() {
+ if _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
+ GroupName: aws.String(groupName),
+ PolicyArn: policyArn,
+ }); err != nil {
+ t.Logf("cleanup: failed to detach group policy: %v", err)
+ }
+ if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil {
+ t.Logf("cleanup: failed to delete group: %v", err)
+ }
+ if _, err := iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}); err != nil {
+ t.Logf("cleanup: failed to delete policy: %v", err)
+ }
+ })
+
+ t.Run("attach_group_policy", func(t *testing.T) {
+ _, err := iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
+ GroupName: aws.String(groupName),
+ PolicyArn: policyArn,
+ })
+ require.NoError(t, err)
+ })
+
+ t.Run("list_attached_group_policies", func(t *testing.T) {
+ resp, err := iamClient.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
+ GroupName: aws.String(groupName),
+ })
+ require.NoError(t, err)
+ found := false
+ for _, p := range resp.AttachedPolicies {
+ if *p.PolicyName == policyName {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "Attached policy should appear in ListAttachedGroupPolicies")
+ })
+
+ t.Run("detach_group_policy", func(t *testing.T) {
+ _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
+ GroupName: aws.String(groupName),
+ PolicyArn: policyArn,
+ })
+ require.NoError(t, err)
+
+ // Verify detachment
+ resp, err := iamClient.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
+ GroupName: aws.String(groupName),
+ })
+ require.NoError(t, err)
+ for _, p := range resp.AttachedPolicies {
+ assert.NotEqual(t, policyName, *p.PolicyName,
+ "Detached policy should not appear in ListAttachedGroupPolicies")
+ }
+ })
+}
+
+// TestIAMGroupPolicyEnforcement tests that group policies are enforced during S3 operations.
+// Creates a user with no direct policies, adds them to a group with S3 access,
+// and verifies they can access S3 through the group policy.
+func TestIAMGroupPolicyEnforcement(t *testing.T) {
+ framework := NewS3IAMTestFramework(t)
+ defer framework.Cleanup()
+
+ iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
+ require.NoError(t, err)
+
+ groupName := "test-enforcement-group"
+ userName := "test-enforcement-user"
+ policyName := "test-enforcement-policy"
+ bucketName := "test-group-enforce-bucket"
+ policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"]}]}`
+
+ // Create user
+ _, err = iamClient.CreateUser(&iam.CreateUserInput{
+ UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+
+ // Create access key for the user
+ keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{
+ UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+
+ accessKeyId := *keyResp.AccessKey.AccessKeyId
+ secretKey := *keyResp.AccessKey.SecretAccessKey
+
+ // Create an S3 client with the user's credentials
+ userS3Client := createS3Client(t, accessKeyId, secretKey)
+
+ // Create group
+ _, err = iamClient.CreateGroup(&iam.CreateGroupInput{
+ GroupName: aws.String(groupName),
+ })
+ require.NoError(t, err)
+
+ // Create policy
+ createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
+ PolicyName: aws.String(policyName),
+ PolicyDocument: aws.String(policyDoc),
+ })
+ require.NoError(t, err)
+ policyArn := createPolicyResp.Policy.Arn
+
+ // Cleanup in correct order: remove user from group, detach policy,
+ // delete access key, delete user, delete group, delete policy
+ t.Cleanup(func() {
+ if _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
+ GroupName: aws.String(groupName),
+ UserName: aws.String(userName),
+ }); err != nil {
+ t.Logf("cleanup: failed to remove user from group: %v", err)
+ }
+ if _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
+ GroupName: aws.String(groupName),
+ PolicyArn: policyArn,
+ }); err != nil {
+ t.Logf("cleanup: failed to detach group policy: %v", err)
+ }
+ if _, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
+ UserName: aws.String(userName),
+ AccessKeyId: keyResp.AccessKey.AccessKeyId,
+ }); err != nil {
+ t.Logf("cleanup: failed to delete access key: %v", err)
+ }
+ if _, err := iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil {
+ t.Logf("cleanup: failed to delete user: %v", err)
+ }
+ if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil {
+ t.Logf("cleanup: failed to delete group: %v", err)
+ }
+ if _, err := iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}); err != nil {
+ t.Logf("cleanup: failed to delete policy: %v", err)
+ }
+ })
+
+ // Register bucket cleanup on parent test with admin credentials
+ // (userS3Client may lack permissions by cleanup time)
+ adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ if _, err := adminS3.DeleteObject(&s3.DeleteObjectInput{
+ Bucket: aws.String(bucketName),
+ Key: aws.String("test-key"),
+ }); err != nil {
+ t.Logf("cleanup: failed to delete object: %v", err)
+ }
+ if _, err := adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}); err != nil {
+ t.Logf("cleanup: failed to delete bucket: %v", err)
+ }
+ })
+
+ t.Run("user_without_group_denied", func(t *testing.T) {
+ // User has no policies and is not in any group — should be denied
+ _, err := userS3Client.CreateBucket(&s3.CreateBucketInput{
+ Bucket: aws.String(bucketName),
+ })
+ require.Error(t, err, "User without any policies should be denied")
+ awsErr, ok := err.(awserr.Error)
+ require.True(t, ok, "Expected awserr.Error")
+ assert.Equal(t, "AccessDenied", awsErr.Code())
+ })
+
+ t.Run("user_with_group_policy_allowed", func(t *testing.T) {
+ // Attach policy to group
+ _, err := iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
+ GroupName: aws.String(groupName),
+ PolicyArn: policyArn,
+ })
+ require.NoError(t, err)
+
+ // Add user to group
+ _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
+ GroupName: aws.String(groupName),
+ UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+
+ // Wait for policy propagation, then create bucket
+ require.Eventually(t, func() bool {
+ _, err = userS3Client.CreateBucket(&s3.CreateBucketInput{
+ Bucket: aws.String(bucketName),
+ })
+ return err == nil
+ }, 10*time.Second, 500*time.Millisecond, "User with group policy should be allowed")
+
+ // Should also be able to put/get objects
+ _, err = userS3Client.PutObject(&s3.PutObjectInput{
+ Bucket: aws.String(bucketName),
+ Key: aws.String("test-key"),
+ Body: aws.ReadSeekCloser(strings.NewReader("test-data")),
+ })
+ require.NoError(t, err, "User should be able to put objects through group policy")
+ })
+
+ t.Run("user_removed_from_group_denied", func(t *testing.T) {
+ // Remove user from group
+ _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
+ GroupName: aws.String(groupName),
+ UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+
+ // Wait for policy propagation — user should now be denied
+ var lastErr error
+ require.Eventually(t, func() bool {
+ _, lastErr = userS3Client.ListObjects(&s3.ListObjectsInput{
+ Bucket: aws.String(bucketName),
+ })
+ return lastErr != nil
+ }, 10*time.Second, 500*time.Millisecond, "User removed from group should be denied")
+ awsErr, ok := lastErr.(awserr.Error)
+ require.True(t, ok, "Expected awserr.Error")
+ assert.Equal(t, "AccessDenied", awsErr.Code())
+ })
+}
+
+// TestIAMGroupDisabledPolicyEnforcement tests that disabled groups do not contribute policies.
+// Uses the raw IAM API (callIAMAPI) since the AWS SDK doesn't support custom group status.
+func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+ if !isSeaweedFSRunning(t) {
+ t.Skip("SeaweedFS is not running at", TestIAMEndpoint)
+ }
+
+ framework := NewS3IAMTestFramework(t)
+ defer framework.Cleanup()
+
+ iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
+ require.NoError(t, err)
+
+ groupName := "test-disabled-group"
+ userName := "test-disabled-grp-user"
+ policyName := "test-disabled-grp-policy"
+ bucketName := "test-disabled-grp-bucket"
+ policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"]}]}`
+
+ // Create user, group, policy
+ _, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
+ require.NoError(t, err)
+
+ keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(userName)})
+ require.NoError(t, err)
+
+ _, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(groupName)})
+ require.NoError(t, err)
+
+ createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{
+ PolicyName: aws.String(policyName), PolicyDocument: aws.String(policyDoc),
+ })
+ require.NoError(t, err)
+
+ // Cleanup in correct order: remove user from group, detach policy,
+ // delete access key, delete user, delete group, delete policy
+ t.Cleanup(func() {
+ if _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
+ GroupName: aws.String(groupName), UserName: aws.String(userName),
+ }); err != nil {
+ t.Logf("cleanup: failed to remove user from group: %v", err)
+ }
+ if _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{
+ GroupName: aws.String(groupName),
+ PolicyArn: aws.String("arn:aws:iam:::policy/" + policyName),
+ }); err != nil {
+ t.Logf("cleanup: failed to detach group policy: %v", err)
+ }
+ if _, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{
+ UserName: aws.String(userName), AccessKeyId: keyResp.AccessKey.AccessKeyId,
+ }); err != nil {
+ t.Logf("cleanup: failed to delete access key: %v", err)
+ }
+ if _, err := iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil {
+ t.Logf("cleanup: failed to delete user: %v", err)
+ }
+ if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil {
+ t.Logf("cleanup: failed to delete group: %v", err)
+ }
+ if _, err := iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: createPolicyResp.Policy.Arn}); err != nil {
+ t.Logf("cleanup: failed to delete policy: %v", err)
+ }
+ })
+
+ // Setup: attach policy, add user, create bucket with admin
+ _, err = iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{
+ GroupName: aws.String(groupName), PolicyArn: createPolicyResp.Policy.Arn,
+ })
+ require.NoError(t, err)
+
+ _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
+ GroupName: aws.String(groupName), UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+
+ userS3Client := createS3Client(t, *keyResp.AccessKey.AccessKeyId, *keyResp.AccessKey.SecretAccessKey)
+
+ // Create bucket using admin first so we can test listing
+ adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole")
+ require.NoError(t, err)
+ _, err = adminS3.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucketName)})
+ require.NoError(t, err)
+ defer adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)})
+
+ t.Run("enabled_group_allows_access", func(t *testing.T) {
+ require.Eventually(t, func() bool {
+ _, err := userS3Client.ListObjects(&s3.ListObjectsInput{
+ Bucket: aws.String(bucketName),
+ })
+ return err == nil
+ }, 10*time.Second, 500*time.Millisecond, "User in enabled group should have access")
+ })
+
+ t.Run("disabled_group_denies_access", func(t *testing.T) {
+ // Disable group via raw IAM API (no SDK support for this extension)
+ resp, err := callIAMAPIAuthenticated(t, framework, "UpdateGroup", url.Values{
+ "GroupName": {groupName},
+ "Disabled": {"true"},
+ })
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode, "UpdateGroup (disable) should return 200")
+
+ // Wait for propagation — user should be denied
+ var lastErr error
+ require.Eventually(t, func() bool {
+ _, lastErr = userS3Client.ListObjects(&s3.ListObjectsInput{
+ Bucket: aws.String(bucketName),
+ })
+ return lastErr != nil
+ }, 10*time.Second, 500*time.Millisecond, "User in disabled group should be denied access")
+ awsErr, ok := lastErr.(awserr.Error)
+ require.True(t, ok, "Expected awserr.Error")
+ assert.Equal(t, "AccessDenied", awsErr.Code())
+ })
+
+ t.Run("re_enabled_group_restores_access", func(t *testing.T) {
+ // Re-enable the group
+ resp, err := callIAMAPIAuthenticated(t, framework, "UpdateGroup", url.Values{
+ "GroupName": {groupName},
+ "Disabled": {"false"},
+ })
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode, "UpdateGroup (re-enable) should return 200")
+
+ // Wait for propagation — user should have access again
+ require.Eventually(t, func() bool {
+ _, err = userS3Client.ListObjects(&s3.ListObjectsInput{
+ Bucket: aws.String(bucketName),
+ })
+ return err == nil
+ }, 10*time.Second, 500*time.Millisecond, "User in re-enabled group should have access again")
+ })
+}
+
+// TestIAMGroupUserDeletionSideEffect tests that deleting a user removes them from all groups.
+func TestIAMGroupUserDeletionSideEffect(t *testing.T) {
+ framework := NewS3IAMTestFramework(t)
+ defer framework.Cleanup()
+
+ iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
+ require.NoError(t, err)
+
+ groupName := "test-deletion-group"
+ userName := "test-deletion-user"
+
+ // Create group and user
+ _, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(groupName)})
+ require.NoError(t, err)
+ defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)})
+
+ _, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ // Best-effort: user may already be deleted by the test
+ iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
+ })
+
+ // Add user to group
+ _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
+ GroupName: aws.String(groupName),
+ UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+
+ // Verify user is in group
+ getResp, err := iamClient.GetGroup(&iam.GetGroupInput{GroupName: aws.String(groupName)})
+ require.NoError(t, err)
+ assert.Len(t, getResp.Users, 1, "Group should have 1 member before deletion")
+
+ // Delete the user
+ _, err = iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
+ require.NoError(t, err)
+
+ // Verify user was removed from the group
+ getResp, err = iamClient.GetGroup(&iam.GetGroupInput{GroupName: aws.String(groupName)})
+ require.NoError(t, err)
+ assert.Empty(t, getResp.Users, "Group should have no members after user deletion")
+}
+
+// TestIAMGroupMultipleGroups tests that a user can belong to multiple groups
+// and inherits policies from all of them.
+func TestIAMGroupMultipleGroups(t *testing.T) {
+ framework := NewS3IAMTestFramework(t)
+ defer framework.Cleanup()
+
+ iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole")
+ require.NoError(t, err)
+
+ group1 := "test-multi-group-1"
+ group2 := "test-multi-group-2"
+ userName := "test-multi-group-user"
+
+ // Create two groups
+ _, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(group1)})
+ require.NoError(t, err)
+ defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(group1)})
+
+ _, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(group2)})
+ require.NoError(t, err)
+ defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(group2)})
+
+ // Create user
+ _, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
+ require.NoError(t, err)
+ defer func() {
+ iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
+ GroupName: aws.String(group1), UserName: aws.String(userName),
+ })
+ iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{
+ GroupName: aws.String(group2), UserName: aws.String(userName),
+ })
+ iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
+ }()
+
+ // Add user to both groups
+ _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
+ GroupName: aws.String(group1), UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+
+ _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{
+ GroupName: aws.String(group2), UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+
+ // Verify user appears in both groups
+ resp, err := iamClient.ListGroupsForUser(&iam.ListGroupsForUserInput{
+ UserName: aws.String(userName),
+ })
+ require.NoError(t, err)
+ groupNames := make(map[string]bool)
+ for _, g := range resp.Groups {
+ groupNames[*g.GroupName] = true
+ }
+ assert.True(t, groupNames[group1], "User should be in group 1")
+ assert.True(t, groupNames[group2], "User should be in group 2")
+}
+
+// --- Response types for raw IAM API calls ---
+
+type CreateGroupResponse struct {
+ XMLName xml.Name `xml:"CreateGroupResponse"`
+ CreateGroupResult struct {
+ Group struct {
+ GroupName string `xml:"GroupName"`
+ } `xml:"Group"`
+ } `xml:"CreateGroupResult"`
+}
+
+type ListGroupsResponse struct {
+ XMLName xml.Name `xml:"ListGroupsResponse"`
+ ListGroupsResult struct {
+ Groups []struct {
+ GroupName string `xml:"GroupName"`
+ } `xml:"Groups>member"`
+ } `xml:"ListGroupsResult"`
+}
+
+// callIAMAPIAuthenticated sends an authenticated raw IAM API request using the
+// framework's JWT token. This is needed for custom extensions not in the AWS SDK
+// (like UpdateGroup with Disabled parameter).
+func callIAMAPIAuthenticated(_ *testing.T, framework *S3IAMTestFramework, action string, params url.Values) (*http.Response, error) {
+ params.Set("Action", action)
+
+ req, err := http.NewRequest(http.MethodPost, TestIAMEndpoint+"/",
+ strings.NewReader(params.Encode()))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ token, err := framework.generateSTSSessionToken("admin-user", "TestAdminRole", time.Hour, "", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ client := &http.Client{
+ Timeout: 30 * time.Second,
+ Transport: &BearerTokenTransport{Token: token},
+ }
+ return client.Do(req)
+}
+
+// TestIAMGroupRawAPI tests group operations using raw HTTP IAM API calls,
+// verifying XML response format for group operations.
+func TestIAMGroupRawAPI(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+ if !isSeaweedFSRunning(t) {
+ t.Skip("SeaweedFS is not running at", TestIAMEndpoint)
+ }
+
+ framework := NewS3IAMTestFramework(t)
+ defer framework.Cleanup()
+
+ groupName := "test-raw-api-group"
+
+ t.Run("create_group_raw", func(t *testing.T) {
+ resp, err := callIAMAPIAuthenticated(t, framework, "CreateGroup", url.Values{
+ "GroupName": {groupName},
+ })
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ var createResp CreateGroupResponse
+ err = xml.Unmarshal(body, &createResp)
+ require.NoError(t, err)
+ assert.Equal(t, groupName, createResp.CreateGroupResult.Group.GroupName)
+ })
+
+ t.Run("list_groups_raw", func(t *testing.T) {
+ resp, err := callIAMAPIAuthenticated(t, framework, "ListGroups", url.Values{})
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ var listResp ListGroupsResponse
+ err = xml.Unmarshal(body, &listResp)
+ require.NoError(t, err)
+
+ found := false
+ for _, g := range listResp.ListGroupsResult.Groups {
+ if g.GroupName == groupName {
+ found = true
+ break
+ }
+ }
+ assert.True(t, found, "Created group should appear in raw ListGroups")
+ })
+
+ t.Run("delete_group_raw", func(t *testing.T) {
+ resp, err := callIAMAPIAuthenticated(t, framework, "DeleteGroup", url.Values{
+ "GroupName": {groupName},
+ })
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ })
+}
+
+// createS3Client creates an S3 client with static credentials
+func createS3Client(t *testing.T, accessKey, secretKey string) *s3.S3 {
+ sess, err := session.NewSession(&aws.Config{
+ Region: aws.String("us-east-1"),
+ Endpoint: aws.String(TestS3Endpoint),
+ Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
+ DisableSSL: aws.Bool(true),
+ S3ForcePathStyle: aws.Bool(true),
+ })
+ require.NoError(t, err)
+ return s3.New(sess)
+}
diff --git a/weed/admin/dash/admin_data.go b/weed/admin/dash/admin_data.go
index cb120d873..46a7ddb14 100644
--- a/weed/admin/dash/admin_data.go
+++ b/weed/admin/dash/admin_data.go
@@ -90,6 +90,7 @@ type UserDetails struct {
Actions []string `json:"actions"`
PolicyNames []string `json:"policy_names"`
AccessKeys []AccessKeyInfo `json:"access_keys"`
+ Groups []string `json:"groups"`
}
type FilerNode struct {
diff --git a/weed/admin/dash/group_management.go b/weed/admin/dash/group_management.go
new file mode 100644
index 000000000..57b217189
--- /dev/null
+++ b/weed/admin/dash/group_management.go
@@ -0,0 +1,250 @@
+package dash
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+ "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+)
+
+// cloneGroup creates a deep copy of an iam_pb.Group to avoid mutating stored state.
+func cloneGroup(g *iam_pb.Group) *iam_pb.Group {
+ clone := &iam_pb.Group{
+ Name: g.Name,
+ Disabled: g.Disabled,
+ }
+ if g.Members != nil {
+ clone.Members = make([]string, len(g.Members))
+ copy(clone.Members, g.Members)
+ }
+ if g.PolicyNames != nil {
+ clone.PolicyNames = make([]string, len(g.PolicyNames))
+ copy(clone.PolicyNames, g.PolicyNames)
+ }
+ return clone
+}
+
+func (s *AdminServer) GetGroups(ctx context.Context) ([]GroupData, error) {
+ if s.credentialManager == nil {
+ return nil, fmt.Errorf("credential manager not available")
+ }
+
+ groupNames, err := s.credentialManager.ListGroups(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list groups: %w", err)
+ }
+
+ var groups []GroupData
+ for _, name := range groupNames {
+ g, err := s.credentialManager.GetGroup(ctx, name)
+ if err != nil {
+ if errors.Is(err, credential.ErrGroupNotFound) {
+ glog.V(1).Infof("Group %s listed but not found, skipping", name)
+ continue
+ }
+ return nil, fmt.Errorf("failed to get group %s: %w", name, err)
+ }
+ status := "enabled"
+ if g.Disabled {
+ status = "disabled"
+ }
+ groups = append(groups, GroupData{
+ Name: g.Name,
+ MemberCount: len(g.Members),
+ PolicyCount: len(g.PolicyNames),
+ Status: status,
+ Members: g.Members,
+ PolicyNames: g.PolicyNames,
+ })
+ }
+ return groups, nil
+}
+
+func (s *AdminServer) GetGroupDetails(ctx context.Context, name string) (*GroupData, error) {
+ if s.credentialManager == nil {
+ return nil, fmt.Errorf("credential manager not available")
+ }
+
+ g, err := s.credentialManager.GetGroup(ctx, name)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get group: %w", err)
+ }
+ status := "enabled"
+ if g.Disabled {
+ status = "disabled"
+ }
+ return &GroupData{
+ Name: g.Name,
+ MemberCount: len(g.Members),
+ PolicyCount: len(g.PolicyNames),
+ Status: status,
+ Members: g.Members,
+ PolicyNames: g.PolicyNames,
+ }, nil
+}
+
+func (s *AdminServer) CreateGroup(ctx context.Context, name string) (*GroupData, error) {
+ if s.credentialManager == nil {
+ return nil, fmt.Errorf("credential manager not available")
+ }
+
+ group := &iam_pb.Group{Name: name}
+ if err := s.credentialManager.CreateGroup(ctx, group); err != nil {
+ return nil, fmt.Errorf("failed to create group: %w", err)
+ }
+ glog.V(1).Infof("Created group %s", group.Name)
+ return &GroupData{
+ Name: group.Name,
+ Status: "enabled",
+ }, nil
+}
+
+func (s *AdminServer) DeleteGroup(ctx context.Context, name string) error {
+ if s.credentialManager == nil {
+ return fmt.Errorf("credential manager not available")
+ }
+ // Check for members and attached policies before deleting (same guards as IAM handlers)
+ g, err := s.credentialManager.GetGroup(ctx, name)
+ if err != nil {
+ return fmt.Errorf("failed to get group: %w", err)
+ }
+ if len(g.Members) > 0 {
+ return fmt.Errorf("cannot delete group %s: group has %d member(s): %w", name, len(g.Members), credential.ErrGroupNotEmpty)
+ }
+ if len(g.PolicyNames) > 0 {
+ return fmt.Errorf("cannot delete group %s: group has %d attached policy(ies): %w", name, len(g.PolicyNames), credential.ErrGroupNotEmpty)
+ }
+ if err := s.credentialManager.DeleteGroup(ctx, name); err != nil {
+ return fmt.Errorf("failed to delete group: %w", err)
+ }
+ glog.V(1).Infof("Deleted group %s", name)
+ return nil
+}
+
+func (s *AdminServer) AddGroupMember(ctx context.Context, groupName, username string) error {
+ if s.credentialManager == nil {
+ return fmt.Errorf("credential manager not available")
+ }
+ g, err := s.credentialManager.GetGroup(ctx, groupName)
+ if err != nil {
+ return fmt.Errorf("failed to get group: %w", err)
+ }
+ g = cloneGroup(g)
+ if _, err := s.credentialManager.GetUser(ctx, username); err != nil {
+ return fmt.Errorf("user %s not found: %w", username, err)
+ }
+ for _, m := range g.Members {
+ if m == username {
+ return nil // already a member
+ }
+ }
+ g.Members = append(g.Members, username)
+ if err := s.credentialManager.UpdateGroup(ctx, g); err != nil {
+ return fmt.Errorf("failed to update group: %w", err)
+ }
+ glog.V(1).Infof("Added user %s to group %s", username, groupName)
+ return nil
+}
+
+func (s *AdminServer) RemoveGroupMember(ctx context.Context, groupName, username string) error {
+ if s.credentialManager == nil {
+ return fmt.Errorf("credential manager not available")
+ }
+ g, err := s.credentialManager.GetGroup(ctx, groupName)
+ if err != nil {
+ return fmt.Errorf("failed to get group: %w", err)
+ }
+ g = cloneGroup(g)
+ found := false
+ var newMembers []string
+ for _, m := range g.Members {
+ if m == username {
+ found = true
+ } else {
+ newMembers = append(newMembers, m)
+ }
+ }
+ if !found {
+ return fmt.Errorf("user %s is not a member of group %s: %w", username, groupName, credential.ErrUserNotInGroup)
+ }
+ g.Members = newMembers
+ if err := s.credentialManager.UpdateGroup(ctx, g); err != nil {
+ return fmt.Errorf("failed to update group: %w", err)
+ }
+ glog.V(1).Infof("Removed user %s from group %s", username, groupName)
+ return nil
+}
+
+func (s *AdminServer) AttachGroupPolicy(ctx context.Context, groupName, policyName string) error {
+ if s.credentialManager == nil {
+ return fmt.Errorf("credential manager not available")
+ }
+ g, err := s.credentialManager.GetGroup(ctx, groupName)
+ if err != nil {
+ return fmt.Errorf("failed to get group: %w", err)
+ }
+ g = cloneGroup(g)
+ if _, err := s.credentialManager.GetPolicy(ctx, policyName); err != nil {
+ return fmt.Errorf("policy %s not found: %w", policyName, err)
+ }
+ for _, p := range g.PolicyNames {
+ if p == policyName {
+ return nil // already attached
+ }
+ }
+ g.PolicyNames = append(g.PolicyNames, policyName)
+ if err := s.credentialManager.UpdateGroup(ctx, g); err != nil {
+ return fmt.Errorf("failed to update group: %w", err)
+ }
+ glog.V(1).Infof("Attached policy %s to group %s", policyName, groupName)
+ return nil
+}
+
+func (s *AdminServer) DetachGroupPolicy(ctx context.Context, groupName, policyName string) error {
+ if s.credentialManager == nil {
+ return fmt.Errorf("credential manager not available")
+ }
+ g, err := s.credentialManager.GetGroup(ctx, groupName)
+ if err != nil {
+ return fmt.Errorf("failed to get group: %w", err)
+ }
+ g = cloneGroup(g)
+ found := false
+ var newPolicies []string
+ for _, p := range g.PolicyNames {
+ if p == policyName {
+ found = true
+ } else {
+ newPolicies = append(newPolicies, p)
+ }
+ }
+ if !found {
+ return fmt.Errorf("policy %s is not attached to group %s: %w", policyName, groupName, credential.ErrPolicyNotAttached)
+ }
+ g.PolicyNames = newPolicies
+ if err := s.credentialManager.UpdateGroup(ctx, g); err != nil {
+ return fmt.Errorf("failed to update group: %w", err)
+ }
+ glog.V(1).Infof("Detached policy %s from group %s", policyName, groupName)
+ return nil
+}
+
+func (s *AdminServer) SetGroupStatus(ctx context.Context, groupName string, enabled bool) error {
+ if s.credentialManager == nil {
+ return fmt.Errorf("credential manager not available")
+ }
+ g, err := s.credentialManager.GetGroup(ctx, groupName)
+ if err != nil {
+ return fmt.Errorf("failed to get group: %w", err)
+ }
+ g = cloneGroup(g)
+ g.Disabled = !enabled
+ if err := s.credentialManager.UpdateGroup(ctx, g); err != nil {
+ return fmt.Errorf("failed to update group: %w", err)
+ }
+ glog.V(1).Infof("Set group %s status to enabled=%v", groupName, enabled)
+ return nil
+}
diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go
index 4dbdc965c..965166de4 100644
--- a/weed/admin/dash/types.go
+++ b/weed/admin/dash/types.go
@@ -589,6 +589,30 @@ type UpdateServiceAccountRequest struct {
Expiration string `json:"expiration,omitempty"`
}
+// Group management structures
+type GroupData struct {
+ Name string `json:"name"`
+ MemberCount int `json:"member_count"`
+ PolicyCount int `json:"policy_count"`
+ Status string `json:"status"` // "enabled" or "disabled"
+ Members []string `json:"members"`
+ PolicyNames []string `json:"policy_names"`
+}
+
+type GroupsPageData struct {
+ Username string `json:"username"`
+ Groups []GroupData `json:"groups"`
+ TotalGroups int `json:"total_groups"`
+ ActiveGroups int `json:"active_groups"`
+ AvailableUsers []string `json:"available_users"`
+ AvailablePolicies []string `json:"available_policies"`
+ LastUpdated time.Time `json:"last_updated"`
+}
+
+type CreateGroupRequest struct {
+ Name string `json:"name"`
+}
+
// STS Configuration display types
type STSConfigData struct {
Enabled bool `json:"enabled"`
diff --git a/weed/admin/dash/user_management.go b/weed/admin/dash/user_management.go
index 3f2d48feb..ecae2169b 100644
--- a/weed/admin/dash/user_management.go
+++ b/weed/admin/dash/user_management.go
@@ -187,6 +187,24 @@ func (s *AdminServer) GetObjectStoreUserDetails(username string) (*UserDetails,
details.Email = identity.Account.EmailAddress
}
+ // Look up groups the user belongs to
+ groupNames, err := s.credentialManager.ListGroups(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list groups: %w", err)
+ }
+ for _, gName := range groupNames {
+ g, err := s.credentialManager.GetGroup(ctx, gName)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get group %s: %w", gName, err)
+ }
+ for _, member := range g.Members {
+ if member == username {
+ details.Groups = append(details.Groups, gName)
+ break
+ }
+ }
+ }
+
// Convert credentials to access key info
for _, cred := range identity.Credentials {
details.AccessKeys = append(details.AccessKeys, AccessKeyInfo{
diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go
index ff0d8651a..38938c25b 100644
--- a/weed/admin/handlers/admin_handlers.go
+++ b/weed/admin/handlers/admin_handlers.go
@@ -28,6 +28,7 @@ type AdminHandlers struct {
pluginHandlers *PluginHandlers
mqHandlers *MessageQueueHandlers
serviceAccountHandlers *ServiceAccountHandlers
+ groupHandlers *GroupHandlers
}
// NewAdminHandlers creates a new instance of AdminHandlers
@@ -40,6 +41,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *Admi
pluginHandlers := NewPluginHandlers(adminServer)
mqHandlers := NewMessageQueueHandlers(adminServer)
serviceAccountHandlers := NewServiceAccountHandlers(adminServer)
+ groupHandlers := NewGroupHandlers(adminServer)
return &AdminHandlers{
adminServer: adminServer,
sessionStore: store,
@@ -51,6 +53,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *Admi
pluginHandlers: pluginHandlers,
mqHandlers: mqHandlers,
serviceAccountHandlers: serviceAccountHandlers,
+ groupHandlers: groupHandlers,
}
}
@@ -104,6 +107,7 @@ func (h *AdminHandlers) registerUIRoutes(r *mux.Router) {
r.HandleFunc("/object-store/buckets/{bucket}", h.ShowBucketDetails).Methods(http.MethodGet)
r.HandleFunc("/object-store/users", h.userHandlers.ShowObjectStoreUsers).Methods(http.MethodGet)
r.HandleFunc("/object-store/policies", h.policyHandlers.ShowPolicies).Methods(http.MethodGet)
+ r.HandleFunc("/object-store/groups", h.groupHandlers.ShowGroups).Methods(http.MethodGet)
r.HandleFunc("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts).Methods(http.MethodGet)
r.HandleFunc("/object-store/s3tables/buckets", h.ShowS3TablesBuckets).Methods(http.MethodGet)
r.HandleFunc("/object-store/s3tables/buckets/{bucket}/namespaces", h.ShowS3TablesNamespaces).Methods(http.MethodGet)
@@ -185,6 +189,19 @@ func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) {
saApi.Handle("/{id}", wrapWrite(h.serviceAccountHandlers.UpdateServiceAccount)).Methods(http.MethodPut)
saApi.Handle("/{id}", wrapWrite(h.serviceAccountHandlers.DeleteServiceAccount)).Methods(http.MethodDelete)
+ groupsApi := api.PathPrefix("/groups").Subrouter()
+ groupsApi.HandleFunc("", h.groupHandlers.GetGroups).Methods(http.MethodGet)
+ groupsApi.Handle("", wrapWrite(h.groupHandlers.CreateGroup)).Methods(http.MethodPost)
+ groupsApi.HandleFunc("/{name}", h.groupHandlers.GetGroupDetails).Methods(http.MethodGet)
+ groupsApi.Handle("/{name}", wrapWrite(h.groupHandlers.DeleteGroup)).Methods(http.MethodDelete)
+ groupsApi.Handle("/{name}/status", wrapWrite(h.groupHandlers.SetGroupStatus)).Methods(http.MethodPut)
+ groupsApi.HandleFunc("/{name}/members", h.groupHandlers.GetGroupMembers).Methods(http.MethodGet)
+ groupsApi.Handle("/{name}/members", wrapWrite(h.groupHandlers.AddGroupMember)).Methods(http.MethodPost)
+ groupsApi.Handle("/{name}/members/{username}", wrapWrite(h.groupHandlers.RemoveGroupMember)).Methods(http.MethodDelete)
+ groupsApi.HandleFunc("/{name}/policies", h.groupHandlers.GetGroupPolicies).Methods(http.MethodGet)
+ groupsApi.Handle("/{name}/policies", wrapWrite(h.groupHandlers.AttachGroupPolicy)).Methods(http.MethodPost)
+ groupsApi.Handle("/{name}/policies/{policyName}", wrapWrite(h.groupHandlers.DetachGroupPolicy)).Methods(http.MethodDelete)
+
policyApi := api.PathPrefix("/object-store/policies").Subrouter()
policyApi.HandleFunc("", h.policyHandlers.GetPolicies).Methods(http.MethodGet)
policyApi.Handle("", wrapWrite(h.policyHandlers.CreatePolicy)).Methods(http.MethodPost)
diff --git a/weed/admin/handlers/group_handlers.go b/weed/admin/handlers/group_handlers.go
new file mode 100644
index 000000000..57fc5d8c6
--- /dev/null
+++ b/weed/admin/handlers/group_handlers.go
@@ -0,0 +1,271 @@
+package handlers
+
+import (
+ "bytes"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/mux"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/app"
+ "github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/glog"
+)
+
+func groupErrorToHTTPStatus(err error) int {
+ if errors.Is(err, credential.ErrGroupNotFound) {
+ return http.StatusNotFound
+ }
+ if errors.Is(err, credential.ErrGroupAlreadyExists) {
+ return http.StatusConflict
+ }
+ if errors.Is(err, credential.ErrUserNotInGroup) {
+ return http.StatusBadRequest
+ }
+ if errors.Is(err, credential.ErrPolicyNotAttached) {
+ return http.StatusBadRequest
+ }
+ if errors.Is(err, credential.ErrUserNotFound) {
+ return http.StatusNotFound
+ }
+ if errors.Is(err, credential.ErrPolicyNotFound) {
+ return http.StatusNotFound
+ }
+ if errors.Is(err, credential.ErrGroupNotEmpty) {
+ return http.StatusConflict
+ }
+ return http.StatusInternalServerError
+}
+
+type GroupHandlers struct {
+ adminServer *dash.AdminServer
+}
+
+func NewGroupHandlers(adminServer *dash.AdminServer) *GroupHandlers {
+ return &GroupHandlers{adminServer: adminServer}
+}
+
+func (h *GroupHandlers) ShowGroups(w http.ResponseWriter, r *http.Request) {
+ data, err := h.getGroupsPageData(r)
+ if err != nil {
+ glog.Errorf("Failed to get groups data: %v", err)
+ writeJSONError(w, http.StatusInternalServerError, "Failed to load groups: "+err.Error())
+ return
+ }
+
+ var buf bytes.Buffer
+ component := app.Groups(data)
+ viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context()))
+ layoutComponent := layout.Layout(viewCtx, component)
+ if err := layoutComponent.Render(r.Context(), &buf); err != nil {
+ glog.Errorf("Failed to render groups template: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html")
+ _, _ = w.Write(buf.Bytes())
+}
+
+func (h *GroupHandlers) GetGroups(w http.ResponseWriter, r *http.Request) {
+ groups, err := h.adminServer.GetGroups(r.Context())
+ if err != nil {
+ glog.Errorf("Failed to get groups: %v", err)
+ writeJSONError(w, http.StatusInternalServerError, "Failed to get groups")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]interface{}{"groups": groups})
+}
+
+func (h *GroupHandlers) CreateGroup(w http.ResponseWriter, r *http.Request) {
+ var req dash.CreateGroupRequest
+ if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
+ return
+ }
+ if req.Name == "" {
+ writeJSONError(w, http.StatusBadRequest, "Group name is required")
+ return
+ }
+ group, err := h.adminServer.CreateGroup(r.Context(), req.Name)
+ if err != nil {
+ glog.Errorf("Failed to create group: %v", err)
+ writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to create group: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, group)
+}
+
+func (h *GroupHandlers) GetGroupDetails(w http.ResponseWriter, r *http.Request) {
+ name := mux.Vars(r)["name"]
+ group, err := h.adminServer.GetGroupDetails(r.Context(), name)
+ if err != nil {
+ glog.Errorf("Failed to get group details: %v", err)
+ status := groupErrorToHTTPStatus(err)
+ msg := "Failed to retrieve group"
+ if status == http.StatusNotFound {
+ msg = "Group not found"
+ }
+ writeJSONError(w, status, msg)
+ return
+ }
+ writeJSON(w, http.StatusOK, group)
+}
+
+func (h *GroupHandlers) DeleteGroup(w http.ResponseWriter, r *http.Request) {
+ name := mux.Vars(r)["name"]
+ if err := h.adminServer.DeleteGroup(r.Context(), name); err != nil {
+ glog.Errorf("Failed to delete group: %v", err)
+ writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to delete group: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "Group deleted successfully"})
+}
+
+func (h *GroupHandlers) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
+ name := mux.Vars(r)["name"]
+ group, err := h.adminServer.GetGroupDetails(r.Context(), name)
+ if err != nil {
+ writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to get group: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]interface{}{"members": group.Members})
+}
+
+func (h *GroupHandlers) AddGroupMember(w http.ResponseWriter, r *http.Request) {
+ name := mux.Vars(r)["name"]
+ var req struct {
+ Username string `json:"username"`
+ }
+ if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
+ return
+ }
+ if req.Username == "" {
+ writeJSONError(w, http.StatusBadRequest, "Username is required")
+ return
+ }
+ if err := h.adminServer.AddGroupMember(r.Context(), name, req.Username); err != nil {
+ writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to add member: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "Member added successfully"})
+}
+
+func (h *GroupHandlers) RemoveGroupMember(w http.ResponseWriter, r *http.Request) {
+ name := mux.Vars(r)["name"]
+ username := mux.Vars(r)["username"]
+ if err := h.adminServer.RemoveGroupMember(r.Context(), name, username); err != nil {
+ writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to remove member: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "Member removed successfully"})
+}
+
+func (h *GroupHandlers) GetGroupPolicies(w http.ResponseWriter, r *http.Request) {
+ name := mux.Vars(r)["name"]
+ group, err := h.adminServer.GetGroupDetails(r.Context(), name)
+ if err != nil {
+ writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to get group: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]interface{}{"policies": group.PolicyNames})
+}
+
+func (h *GroupHandlers) AttachGroupPolicy(w http.ResponseWriter, r *http.Request) {
+ name := mux.Vars(r)["name"]
+ var req struct {
+ PolicyName string `json:"policy_name"`
+ }
+ if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
+ return
+ }
+ if req.PolicyName == "" {
+ writeJSONError(w, http.StatusBadRequest, "Policy name is required")
+ return
+ }
+ if err := h.adminServer.AttachGroupPolicy(r.Context(), name, req.PolicyName); err != nil {
+ writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to attach policy: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "Policy attached successfully"})
+}
+
+func (h *GroupHandlers) DetachGroupPolicy(w http.ResponseWriter, r *http.Request) {
+ name := mux.Vars(r)["name"]
+ policyName := mux.Vars(r)["policyName"]
+ if err := h.adminServer.DetachGroupPolicy(r.Context(), name, policyName); err != nil {
+ writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to detach policy: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "Policy detached successfully"})
+}
+
+func (h *GroupHandlers) SetGroupStatus(w http.ResponseWriter, r *http.Request) {
+ name := mux.Vars(r)["name"]
+ var req struct {
+ Enabled *bool `json:"enabled"`
+ }
+ if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
+ writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
+ return
+ }
+ if req.Enabled == nil {
+ writeJSONError(w, http.StatusBadRequest, "enabled field is required")
+ return
+ }
+ if err := h.adminServer.SetGroupStatus(r.Context(), name, *req.Enabled); err != nil {
+ writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to update group status: "+err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "Group status updated"})
+}
+
+func (h *GroupHandlers) getGroupsPageData(r *http.Request) (dash.GroupsPageData, error) {
+ username := dash.UsernameFromContext(r.Context())
+ if username == "" {
+ username = "admin"
+ }
+
+ groups, err := h.adminServer.GetGroups(r.Context())
+ if err != nil {
+ return dash.GroupsPageData{}, err
+ }
+
+ activeCount := 0
+ for _, g := range groups {
+ if g.Status == "enabled" {
+ activeCount++
+ }
+ }
+
+ // Get available users for dropdown
+ var availableUsers []string
+ users, err := h.adminServer.GetObjectStoreUsers(r.Context())
+ if err == nil {
+ for _, user := range users {
+ availableUsers = append(availableUsers, user.Username)
+ }
+ }
+
+ // Get available policies for dropdown
+ var availablePolicies []string
+ policies, err := h.adminServer.GetPolicies()
+ if err == nil {
+ for _, p := range policies {
+ availablePolicies = append(availablePolicies, p.Name)
+ }
+ }
+
+ return dash.GroupsPageData{
+ Username: username,
+ Groups: groups,
+ TotalGroups: len(groups),
+ ActiveGroups: activeCount,
+ AvailableUsers: availableUsers,
+ AvailablePolicies: availablePolicies,
+ LastUpdated: time.Now(),
+ }, nil
+}
diff --git a/weed/admin/static/js/admin.js b/weed/admin/static/js/admin.js
index 7891645c7..316f9d2a0 100644
--- a/weed/admin/static/js/admin.js
+++ b/weed/admin/static/js/admin.js
@@ -478,7 +478,7 @@ async function handleCreateBucket(event) {
if (response.ok) {
// Success
- showAlert('success', `Bucket "${bucketData.name}" created successfully!`);
+ showAlert(`Bucket "${bucketData.name}" created successfully!`, 'success');
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal'));
@@ -493,11 +493,11 @@ async function handleCreateBucket(event) {
}, 1500);
} else {
// Error
- showAlert('danger', result.error || 'Failed to create bucket');
+ showAlert(result.error || 'Failed to create bucket', 'danger');
}
} catch (error) {
console.error('Error creating bucket:', error);
- showAlert('danger', 'Network error occurred while creating bucket');
+ showAlert('Network error occurred while creating bucket', 'danger');
}
}
@@ -538,7 +538,7 @@ async function deleteBucket() {
if (response.ok) {
// Success
- showAlert('success', `Bucket "${bucketToDelete}" deleted successfully!`);
+ showAlert(`Bucket "${bucketToDelete}" deleted successfully!`, 'success');
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteBucketModal'));
@@ -550,11 +550,11 @@ async function deleteBucket() {
}, 1500);
} else {
// Error
- showAlert('danger', result.error || 'Failed to delete bucket');
+ showAlert(result.error || 'Failed to delete bucket', 'danger');
}
} catch (error) {
console.error('Error deleting bucket:', error);
- showAlert('danger', 'Network error occurred while deleting bucket');
+ showAlert('Network error occurred while deleting bucket', 'danger');
}
bucketToDelete = '';
@@ -609,38 +609,7 @@ function exportBucketList() {
window.URL.revokeObjectURL(url);
}
-// Show alert message
-function showAlert(type, message) {
- // Remove existing alerts
- const existingAlerts = document.querySelectorAll('.alert-floating');
- existingAlerts.forEach(alert => alert.remove());
-
- // Create new alert
- const alert = document.createElement('div');
- alert.className = `alert alert-${type} alert-dismissible fade show alert-floating`;
- alert.style.cssText = `
- position: fixed;
- top: 20px;
- right: 20px;
- z-index: 9999;
- min-width: 300px;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
- `;
-
- alert.innerHTML = `
- ${message}
-
- `;
-
- document.body.appendChild(alert);
-
- // Auto-remove after 5 seconds
- setTimeout(() => {
- if (alert.parentNode) {
- alert.remove();
- }
- }, 5000);
-}
+// showAlert is provided by modal-alerts.js with signature: showAlert(message, type)
// Format date for display
function formatDate(date) {
@@ -651,7 +620,7 @@ function formatDate(date) {
function adminCopyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
- showAlert('success', 'Copied to clipboard!');
+ showAlert('Copied to clipboard!', 'success');
}).catch(err => {
console.error('Failed to copy text: ', err);
fallbackCopyText(text);
@@ -677,13 +646,13 @@ function fallbackCopyText(text) {
try {
const successful = document.execCommand('copy');
if (successful) {
- showAlert('success', 'Copied to clipboard!');
+ showAlert('Copied to clipboard!', 'success');
} else {
- showAlert('danger', 'Failed to copy to clipboard');
+ showAlert('Failed to copy to clipboard', 'danger');
}
} catch (err) {
console.error('Fallback copy failed: ', err);
- showAlert('danger', 'Failed to copy to clipboard');
+ showAlert('Failed to copy to clipboard', 'danger');
}
document.body.removeChild(textArea);
@@ -764,7 +733,7 @@ function exportVolumes() {
function exportCollections() {
const table = document.getElementById('collectionsTable');
if (!table) {
- showAlert('error', 'Collections table not found');
+ showAlert('Collections table not found', 'error');
return;
}
@@ -800,7 +769,7 @@ function exportCollections() {
function exportMasters() {
const table = document.getElementById('mastersTable');
if (!table) {
- showAlert('error', 'Masters table not found');
+ showAlert('Masters table not found', 'error');
return;
}
@@ -834,7 +803,7 @@ function exportMasters() {
function exportFilers() {
const table = document.getElementById('filersTable');
if (!table) {
- showAlert('error', 'Filers table not found');
+ showAlert('Filers table not found', 'error');
return;
}
@@ -870,7 +839,7 @@ function exportFilers() {
function exportUsers() {
const table = document.getElementById('usersTable');
if (!table) {
- showAlert('error', 'Users table not found');
+ showAlert('Users table not found', 'error');
return;
}
@@ -1020,7 +989,7 @@ function confirmDeleteSelected() {
const selectedPaths = getSelectedFilePaths();
if (selectedPaths.length === 0) {
- showAlert('warning', 'No files selected');
+ showAlert('No files selected', 'warning');
return;
}
@@ -1041,7 +1010,7 @@ function confirmDeleteSelected() {
// Delete multiple selected files
async function deleteSelectedFiles(filePaths) {
if (!filePaths || filePaths.length === 0) {
- showAlert('warning', 'No files selected');
+ showAlert('No files selected', 'warning');
return;
}
@@ -1065,9 +1034,9 @@ async function deleteSelectedFiles(filePaths) {
if (result.deleted > 0) {
if (result.failed === 0) {
- showAlert('success', `Successfully deleted ${result.deleted} item(s)`);
+ showAlert(`Successfully deleted ${result.deleted} item(s)`, 'success');
} else {
- showAlert('warning', `Deleted ${result.deleted} item(s), failed to delete ${result.failed} item(s)`);
+ showAlert(`Deleted ${result.deleted} item(s), failed to delete ${result.failed} item(s)`, 'warning');
if (result.errors && result.errors.length > 0) {
console.warn('Deletion errors:', result.errors);
}
@@ -1082,15 +1051,15 @@ async function deleteSelectedFiles(filePaths) {
if (result.errors && result.errors.length > 0) {
errorMessage += ': ' + result.errors.join(', ');
}
- showAlert('error', errorMessage);
+ showAlert(errorMessage, 'error');
}
} else {
const error = await response.json();
- showAlert('error', `Failed to delete files: ${error.error || 'Unknown error'}`);
+ showAlert(`Failed to delete files: ${error.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Delete error:', error);
- showAlert('error', 'Failed to delete files');
+ showAlert('Failed to delete files', 'error');
} finally {
// Re-enable the button
deleteBtn.disabled = false;
@@ -1311,7 +1280,7 @@ async function submitUploadFile() {
function exportFileList() {
const table = document.getElementById('fileTable');
if (!table) {
- showAlert('error', 'File table not found');
+ showAlert('File table not found', 'error');
return;
}
@@ -1357,7 +1326,7 @@ async function viewFile(filePath) {
if (!response.ok) {
const error = await response.json();
- showAlert('error', `Failed to view file: ${error.error || 'Unknown error'}`);
+ showAlert(`Failed to view file: ${error.error || 'Unknown error'}`, 'error');
return;
}
@@ -1366,7 +1335,7 @@ async function viewFile(filePath) {
} catch (error) {
console.error('View file error:', error);
- showAlert('error', 'Failed to view file');
+ showAlert('Failed to view file', 'error');
}
}
@@ -1377,7 +1346,7 @@ async function showProperties(filePath) {
if (!response.ok) {
const error = await response.json();
- showAlert('error', `Failed to get file properties: ${error.error || 'Unknown error'}`);
+ showAlert(`Failed to get file properties: ${error.error || 'Unknown error'}`, 'error');
return;
}
@@ -1386,7 +1355,7 @@ async function showProperties(filePath) {
} catch (error) {
console.error('Properties error:', error);
- showAlert('error', 'Failed to get file properties');
+ showAlert('Failed to get file properties', 'error');
}
}
@@ -1413,16 +1382,16 @@ async function deleteFile(filePath) {
});
if (response.ok) {
- showAlert('success', `Successfully deleted "${filePath}"`);
+ showAlert(`Successfully deleted "${filePath}"`, 'success');
// Reload the page to update the file list
window.location.reload();
} else {
const error = await response.json();
- showAlert('error', `Failed to delete file: ${error.error || 'Unknown error'}`);
+ showAlert(`Failed to delete file: ${error.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Delete error:', error);
- showAlert('error', 'Failed to delete file');
+ showAlert('Failed to delete file', 'error');
}
}
@@ -1737,7 +1706,7 @@ async function handleUpdateQuota(event) {
if (response.ok) {
// Success
- showAlert('success', `Quota for bucket "${bucketName}" updated successfully!`);
+ showAlert(`Quota for bucket "${bucketName}" updated successfully!`, 'success');
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('manageQuotaModal'));
@@ -1749,11 +1718,11 @@ async function handleUpdateQuota(event) {
}, 1500);
} else {
// Error
- showAlert('danger', result.error || 'Failed to update bucket quota');
+ showAlert(result.error || 'Failed to update bucket quota', 'danger');
}
} catch (error) {
console.error('Error updating bucket quota:', error);
- showAlert('danger', 'Network error occurred while updating bucket quota');
+ showAlert('Network error occurred while updating bucket quota', 'danger');
}
}
@@ -2274,21 +2243,21 @@ function copyFromInput(inputId) {
try {
const successful = document.execCommand('copy');
if (successful) {
- showAlert('success', 'Copied to clipboard!');
+ showAlert('Copied to clipboard!', 'success');
} else {
// Try modern clipboard API as fallback
navigator.clipboard.writeText(input.value).then(() => {
- showAlert('success', 'Copied to clipboard!');
+ showAlert('Copied to clipboard!', 'success');
}).catch(() => {
- showAlert('danger', 'Failed to copy');
+ showAlert('Failed to copy', 'danger');
});
}
} catch (err) {
// Try modern clipboard API as fallback
navigator.clipboard.writeText(input.value).then(() => {
- showAlert('success', 'Copied to clipboard!');
+ showAlert('Copied to clipboard!', 'success');
}).catch(() => {
- showAlert('danger', 'Failed to copy');
+ showAlert('Failed to copy', 'danger');
});
}
}
diff --git a/weed/admin/static/js/iam-utils.js b/weed/admin/static/js/iam-utils.js
index baf8ba457..1b50d54a6 100644
--- a/weed/admin/static/js/iam-utils.js
+++ b/weed/admin/static/js/iam-utils.js
@@ -25,6 +25,29 @@ async function deleteUser(username) {
}, 'Are you sure you want to delete this user? This action cannot be undone.');
}
+// Delete group function
+async function deleteGroup(name) {
+ showDeleteConfirm(name, async function () {
+ try {
+ const encodedName = encodeURIComponent(name);
+ const response = await fetch(`/api/groups/${encodedName}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ showAlert('Group deleted successfully', 'success');
+ setTimeout(() => window.location.reload(), 1000);
+ } else {
+ const error = await response.json().catch(() => ({}));
+ showAlert('Failed to delete group: ' + (error.error || 'Unknown error'), 'error');
+ }
+ } catch (error) {
+ console.error('Error deleting group:', error);
+ showAlert('Failed to delete group: ' + error.message, 'error');
+ }
+ }, 'Are you sure you want to delete this group? This action cannot be undone.');
+}
+
// Delete access key function
async function deleteAccessKey(username, accessKey) {
showDeleteConfirm(accessKey, async function () {
diff --git a/weed/admin/view/app/groups.templ b/weed/admin/view/app/groups.templ
new file mode 100644
index 000000000..fe3c1b8d9
--- /dev/null
+++ b/weed/admin/view/app/groups.templ
@@ -0,0 +1,443 @@
+package app
+
+import (
+ "fmt"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+)
+
+templ Groups(data dash.GroupsPageData) {
+
+
+
+
+
+ Groups
+
+
Manage IAM groups for organizing users and policies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total Groups
+
+
+ {fmt.Sprintf("%d", data.TotalGroups)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Active Groups
+
+
+ {fmt.Sprintf("%d", data.ActiveGroups)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ if len(data.Groups) == 0 {
+
+
+
No groups found. Create a group to get started.
+
+ } else {
+
+
+
+
+ | Name |
+ Members |
+ Policies |
+ Status |
+ Actions |
+
+
+
+ for _, group := range data.Groups {
+
+ |
+ {group.Name}
+ |
+
+ {fmt.Sprintf("%d", group.MemberCount)}
+ |
+
+ {fmt.Sprintf("%d", group.PolicyCount)}
+ |
+
+ if group.Status == "enabled" {
+ Enabled
+ } else {
+ Disabled
+ }
+ |
+
+
+
+ |
+
+ }
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/weed/admin/view/app/groups_templ.go b/weed/admin/view/app/groups_templ.go
new file mode 100644
index 000000000..ed651fbe3
--- /dev/null
+++ b/weed/admin/view/app/groups_templ.go
@@ -0,0 +1,300 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.977
+package app
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "github.com/seaweedfs/seaweedfs/weed/admin/dash"
+)
+
+func Groups(data dash.GroupsPageData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Groups
Manage IAM groups for organizing users and policies
Total Groups
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalGroups))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/groups.templ`, Line: 38, Col: 72}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Active Groups
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.ActiveGroups))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/groups.templ`, Line: 57, Col: 73}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Groups) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
No groups found. Create a group to get started.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
| Name | Members | Policies | Status | Actions |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, group := range data.Groups {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "| ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(group.Name)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/groups.templ`, Line: 96, Col: 63}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", group.MemberCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/groups.templ`, Line: 99, Col: 109}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", group.PolicyCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/groups.templ`, Line: 102, Col: 114}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if group.Status == "enabled" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Enabled")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Disabled")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " | |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/weed/admin/view/app/object_store_users.templ b/weed/admin/view/app/object_store_users.templ
index 9a864bad5..86715dc54 100644
--- a/weed/admin/view/app/object_store_users.templ
+++ b/weed/admin/view/app/object_store_users.templ
@@ -384,6 +384,21 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
+
+
+
+
+
+
+
+
+
+
';
+ detailsHtml += 'Groups
';
+ detailsHtml += '';
+ if (user.groups && user.groups.length > 0) {
+ detailsHtml += user.groups.map(function(group) {
+ return '' + escapeHtml(group) + '';
+ }).join('');
+ } else {
+ detailsHtml += 'No groups';
+ }
+ detailsHtml += '
';
detailsHtml += 'Access Keys
';
if (user.access_keys && user.access_keys.length > 0) {
detailsHtml += '';
diff --git a/weed/admin/view/app/object_store_users_templ.go b/weed/admin/view/app/object_store_users_templ.go
index 34b8cf517..7f2a73c34 100644
--- a/weed/admin/view/app/object_store_users_templ.go
+++ b/weed/admin/view/app/object_store_users_templ.go
@@ -193,7 +193,7 @@ func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
Access Keys for
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Access Keys for
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ
index 6e87b462a..d717548fa 100644
--- a/weed/admin/view/layout/layout.templ
+++ b/weed/admin/view/layout/layout.templ
@@ -168,6 +168,11 @@ templ Layout(view ViewContext, content templ.Component) {
Users
+
+
+ Groups
+
+
Service Accounts
diff --git a/weed/admin/view/layout/layout_templ.go b/weed/admin/view/layout/layout_templ.go
index 62119fe32..ed7b510eb 100644
--- a/weed/admin/view/layout/layout_templ.go
+++ b/weed/admin/view/layout/layout_templ.go
@@ -198,7 +198,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" id=\"storageSubmenu\">- File Browser
- ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" id=\"storageSubmenu\">
- File Browser
- ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -344,7 +344,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year()))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 331, Col: 60}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 336, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -357,7 +357,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 331, Col: 102}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 336, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -409,7 +409,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 359, Col: 17}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 364, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -422,7 +422,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 373, Col: 57}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 378, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -440,7 +440,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 380, Col: 45}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 385, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -458,7 +458,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 385, Col: 84}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 390, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
diff --git a/weed/credential/credential_manager.go b/weed/credential/credential_manager.go
index e78cbf4c9..d9df17e50 100644
--- a/weed/credential/credential_manager.go
+++ b/weed/credential/credential_manager.go
@@ -236,3 +236,25 @@ func (cm *CredentialManager) DetachUserPolicy(ctx context.Context, username stri
func (cm *CredentialManager) ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error) {
return cm.Store.ListAttachedUserPolicies(ctx, username)
}
+
+// Group Management
+
+func (cm *CredentialManager) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
+ return cm.Store.CreateGroup(ctx, group)
+}
+
+func (cm *CredentialManager) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
+ return cm.Store.GetGroup(ctx, groupName)
+}
+
+func (cm *CredentialManager) DeleteGroup(ctx context.Context, groupName string) error {
+ return cm.Store.DeleteGroup(ctx, groupName)
+}
+
+func (cm *CredentialManager) ListGroups(ctx context.Context) ([]string, error) {
+ return cm.Store.ListGroups(ctx)
+}
+
+func (cm *CredentialManager) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
+ return cm.Store.UpdateGroup(ctx, group)
+}
diff --git a/weed/credential/credential_store.go b/weed/credential/credential_store.go
index 0458677b9..f7972e78b 100644
--- a/weed/credential/credential_store.go
+++ b/weed/credential/credential_store.go
@@ -18,6 +18,10 @@ var (
ErrPolicyNotFound = errors.New("policy not found")
ErrPolicyAlreadyAttached = errors.New("policy already attached")
ErrPolicyNotAttached = errors.New("policy not attached to user")
+ ErrGroupNotFound = errors.New("group not found")
+ ErrGroupAlreadyExists = errors.New("group already exists")
+ ErrGroupNotEmpty = errors.New("group is not empty")
+ ErrUserNotInGroup = errors.New("user is not a member of the group")
)
// CredentialStoreTypeName represents the type name of a credential store
@@ -94,6 +98,13 @@ type CredentialStore interface {
// ListAttachedUserPolicies returns the list of policy names attached to a user
ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error)
+ // Group Management
+ CreateGroup(ctx context.Context, group *iam_pb.Group) error
+ GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error)
+ DeleteGroup(ctx context.Context, groupName string) error
+ ListGroups(ctx context.Context) ([]string, error)
+ UpdateGroup(ctx context.Context, group *iam_pb.Group) error
+
// Shutdown performs cleanup when the store is being shut down
Shutdown()
}
diff --git a/weed/credential/filer_etc/filer_etc_group.go b/weed/credential/filer_etc/filer_etc_group.go
new file mode 100644
index 000000000..1a381fb93
--- /dev/null
+++ b/weed/credential/filer_etc/filer_etc_group.go
@@ -0,0 +1,182 @@
+package filer_etc
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/filer"
+ "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+ "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+)
+
+const IamGroupsDirectory = "groups"
+
+func (store *FilerEtcStore) loadGroupsFromMultiFile(ctx context.Context, s3cfg *iam_pb.S3ApiConfiguration) error {
+ return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ dir := filer.IamConfigDirectory + "/" + IamGroupsDirectory
+ entries, err := listEntries(ctx, client, dir)
+ if err != nil {
+ if errors.Is(err, filer_pb.ErrNotFound) {
+ return nil
+ }
+ return err
+ }
+
+ for _, entry := range entries {
+ if entry.IsDirectory {
+ continue
+ }
+
+ var content []byte
+ if len(entry.Content) > 0 {
+ content = entry.Content
+ } else {
+ c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name)
+ if err != nil {
+ return fmt.Errorf("failed to read group file %s: %w", entry.Name, err)
+ }
+ content = c
+ }
+
+ if len(content) > 0 {
+ g := &iam_pb.Group{}
+ if err := json.Unmarshal(content, g); err != nil {
+ return fmt.Errorf("failed to unmarshal group %s: %w", entry.Name, err)
+ }
+ // Merge: overwrite existing group with same name or append
+ found := false
+ for i, existing := range s3cfg.Groups {
+ if existing.Name == g.Name {
+ s3cfg.Groups[i] = g
+ found = true
+ break
+ }
+ }
+ if !found {
+ s3cfg.Groups = append(s3cfg.Groups, g)
+ }
+ }
+ }
+ return nil
+ })
+}
+
+func (store *FilerEtcStore) saveGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group == nil {
+ return fmt.Errorf("group is nil")
+ }
+ group.Name = strings.TrimSpace(group.Name)
+ if group.Name == "" {
+ return fmt.Errorf("group name is required")
+ }
+ return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ data, err := json.MarshalIndent(group, "", " ")
+ if err != nil {
+ return err
+ }
+ return filer.SaveInsideFiler(client, filer.IamConfigDirectory+"/"+IamGroupsDirectory, group.Name+".json", data)
+ })
+}
+
+func (store *FilerEtcStore) deleteGroupFile(ctx context.Context, groupName string) error {
+ return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
+ Directory: filer.IamConfigDirectory + "/" + IamGroupsDirectory,
+ Name: groupName + ".json",
+ })
+ if err != nil {
+ if strings.Contains(err.Error(), filer_pb.ErrNotFound.Error()) {
+ return credential.ErrGroupNotFound
+ }
+ return err
+ }
+ if resp != nil && resp.Error != "" {
+ if strings.Contains(resp.Error, filer_pb.ErrNotFound.Error()) {
+ return credential.ErrGroupNotFound
+ }
+ return fmt.Errorf("delete group %s: %s", groupName, resp.Error)
+ }
+ return nil
+ })
+}
+
+func (store *FilerEtcStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group != nil {
+ group.Name = strings.TrimSpace(group.Name)
+ }
+ if group == nil || group.Name == "" {
+ return fmt.Errorf("group name is required")
+ }
+ existing, err := store.GetGroup(ctx, group.Name)
+ if err != nil {
+ if !errors.Is(err, credential.ErrGroupNotFound) {
+ return err
+ }
+ } else if existing != nil {
+ return credential.ErrGroupAlreadyExists
+ }
+ return store.saveGroup(ctx, group)
+}
+
+func (store *FilerEtcStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
+ var group *iam_pb.Group
+ err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ data, err := filer.ReadInsideFiler(ctx, client, filer.IamConfigDirectory+"/"+IamGroupsDirectory, groupName+".json")
+ if err != nil {
+ if errors.Is(err, filer_pb.ErrNotFound) {
+ return credential.ErrGroupNotFound
+ }
+ return err
+ }
+ if len(data) == 0 {
+ return credential.ErrGroupNotFound
+ }
+ group = &iam_pb.Group{}
+ return json.Unmarshal(data, group)
+ })
+ return group, err
+}
+
+func (store *FilerEtcStore) DeleteGroup(ctx context.Context, groupName string) error {
+ if _, err := store.GetGroup(ctx, groupName); err != nil {
+ return err
+ }
+ return store.deleteGroupFile(ctx, groupName)
+}
+
+func (store *FilerEtcStore) ListGroups(ctx context.Context) ([]string, error) {
+ var names []string
+ err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ entries, err := listEntries(ctx, client, filer.IamConfigDirectory+"/"+IamGroupsDirectory)
+ if err != nil {
+ if errors.Is(err, filer_pb.ErrNotFound) {
+ return nil
+ }
+ return err
+ }
+ for _, entry := range entries {
+ if !entry.IsDirectory && strings.HasSuffix(entry.Name, ".json") {
+ names = append(names, strings.TrimSuffix(entry.Name, ".json"))
+ }
+ }
+ return nil
+ })
+ return names, err
+}
+
+func (store *FilerEtcStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group != nil {
+ group.Name = strings.TrimSpace(group.Name)
+ }
+ if group == nil || group.Name == "" {
+ return fmt.Errorf("group name is required")
+ }
+ if _, err := store.GetGroup(ctx, group.Name); err != nil {
+ return err
+ }
+ return store.saveGroup(ctx, group)
+}
diff --git a/weed/credential/filer_etc/filer_etc_identity.go b/weed/credential/filer_etc/filer_etc_identity.go
index 56af5381b..9c4900ab1 100644
--- a/weed/credential/filer_etc/filer_etc_identity.go
+++ b/weed/credential/filer_etc/filer_etc_identity.go
@@ -45,6 +45,11 @@ func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3Ap
return s3cfg, fmt.Errorf("failed to load service accounts: %w", err)
}
+ // 3b. Load groups
+ if err := store.loadGroupsFromMultiFile(ctx, s3cfg); err != nil {
+ return s3cfg, fmt.Errorf("failed to load groups: %w", err)
+ }
+
// 4. Perform migration if we loaded legacy config
// This ensures that all identities (including legacy ones) are written to individual files
// and the legacy file is renamed.
@@ -144,7 +149,14 @@ func (store *FilerEtcStore) migrateToMultiFile(ctx context.Context, s3cfg *iam_p
}
}
- // 3. Rename legacy file
+ // 3. Save all groups
+ for _, g := range s3cfg.Groups {
+ if err := store.saveGroup(ctx, g); err != nil {
+ return err
+ }
+ }
+
+ // 4. Rename legacy file
return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
_, err := client.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{
OldDirectory: filer.IamConfigDirectory,
@@ -171,6 +183,13 @@ func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_p
}
}
+ // 2b. Save all groups
+ for _, g := range config.Groups {
+ if err := store.saveGroup(ctx, g); err != nil {
+ return err
+ }
+ }
+
// 3. Cleanup removed identities (Full Sync)
if err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
dir := filer.IamConfigDirectory + "/" + IamIdentitiesDirectory
@@ -234,6 +253,40 @@ func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_p
return err
}
+ // 5. Cleanup removed groups (Full Sync)
+ if err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error {
+ dir := filer.IamConfigDirectory + "/" + IamGroupsDirectory
+ entries, err := listEntries(ctx, client, dir)
+ if err != nil {
+ if err == filer_pb.ErrNotFound {
+ return nil
+ }
+ return err
+ }
+
+ validNames := make(map[string]bool)
+ for _, g := range config.Groups {
+ validNames[g.Name+".json"] = true
+ }
+
+ for _, entry := range entries {
+ if !entry.IsDirectory && !validNames[entry.Name] {
+ resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
+ Directory: dir,
+ Name: entry.Name,
+ })
+ if err != nil {
+ glog.Warningf("Failed to delete obsolete group file %s: %v", entry.Name, err)
+ } else if resp != nil && resp.Error != "" {
+ glog.Warningf("Failed to delete obsolete group file %s: %s", entry.Name, resp.Error)
+ }
+ }
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+
return nil
}
diff --git a/weed/credential/grpc/grpc_group.go b/weed/credential/grpc/grpc_group.go
new file mode 100644
index 000000000..be0c155db
--- /dev/null
+++ b/weed/credential/grpc/grpc_group.go
@@ -0,0 +1,87 @@
+package grpc
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+)
+
+// NOTE: The gRPC store uses a load-modify-save pattern for all operations,
+// which is inherently subject to race conditions under concurrent access.
+// This matches the existing pattern used for identities and policies.
+// A future improvement would add dedicated gRPC RPCs for atomic group operations.
+
+func (store *IamGrpcStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group == nil || group.Name == "" {
+ return fmt.Errorf("group name is required")
+ }
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return err
+ }
+ for _, g := range config.Groups {
+ if g.Name == group.Name {
+ return credential.ErrGroupAlreadyExists
+ }
+ }
+ config.Groups = append(config.Groups, group)
+ return store.SaveConfiguration(ctx, config)
+}
+
+func (store *IamGrpcStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for _, g := range config.Groups {
+ if g.Name == groupName {
+ return g, nil
+ }
+ }
+ return nil, credential.ErrGroupNotFound
+}
+
+func (store *IamGrpcStore) DeleteGroup(ctx context.Context, groupName string) error {
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return err
+ }
+ for i, g := range config.Groups {
+ if g.Name == groupName {
+ config.Groups = append(config.Groups[:i], config.Groups[i+1:]...)
+ return store.SaveConfiguration(ctx, config)
+ }
+ }
+ return credential.ErrGroupNotFound
+}
+
+func (store *IamGrpcStore) ListGroups(ctx context.Context) ([]string, error) {
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var names []string
+ for _, g := range config.Groups {
+ names = append(names, g.Name)
+ }
+ return names, nil
+}
+
+func (store *IamGrpcStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group == nil || group.Name == "" {
+ return fmt.Errorf("group name is required")
+ }
+ config, err := store.LoadConfiguration(ctx)
+ if err != nil {
+ return err
+ }
+ for i, g := range config.Groups {
+ if g.Name == group.Name {
+ config.Groups[i] = group
+ return store.SaveConfiguration(ctx, config)
+ }
+ }
+ return credential.ErrGroupNotFound
+}
diff --git a/weed/credential/memory/memory_group.go b/weed/credential/memory/memory_group.go
new file mode 100644
index 000000000..b9e1ca067
--- /dev/null
+++ b/weed/credential/memory/memory_group.go
@@ -0,0 +1,89 @@
+package memory
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+)
+
+// cloneGroup creates a deep copy of an iam_pb.Group.
+func cloneGroup(g *iam_pb.Group) *iam_pb.Group {
+ if g == nil {
+ return nil
+ }
+ clone := &iam_pb.Group{
+ Name: g.Name,
+ Disabled: g.Disabled,
+ }
+ if g.Members != nil {
+ clone.Members = make([]string, len(g.Members))
+ copy(clone.Members, g.Members)
+ }
+ if g.PolicyNames != nil {
+ clone.PolicyNames = make([]string, len(g.PolicyNames))
+ copy(clone.PolicyNames, g.PolicyNames)
+ }
+ return clone
+}
+
+func (store *MemoryStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group == nil || group.Name == "" {
+ return fmt.Errorf("group name is required")
+ }
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if _, exists := store.groups[group.Name]; exists {
+ return credential.ErrGroupAlreadyExists
+ }
+ store.groups[group.Name] = cloneGroup(group)
+ return nil
+}
+
+func (store *MemoryStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ if g, exists := store.groups[groupName]; exists {
+ return cloneGroup(g), nil
+ }
+ return nil, credential.ErrGroupNotFound
+}
+
+func (store *MemoryStore) DeleteGroup(ctx context.Context, groupName string) error {
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if _, exists := store.groups[groupName]; !exists {
+ return credential.ErrGroupNotFound
+ }
+ delete(store.groups, groupName)
+ return nil
+}
+
+func (store *MemoryStore) ListGroups(ctx context.Context) ([]string, error) {
+ store.mu.RLock()
+ defer store.mu.RUnlock()
+
+ var names []string
+ for name := range store.groups {
+ names = append(names, name)
+ }
+ return names, nil
+}
+
+func (store *MemoryStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group == nil || group.Name == "" {
+ return fmt.Errorf("group name is required")
+ }
+ store.mu.Lock()
+ defer store.mu.Unlock()
+
+ if _, exists := store.groups[group.Name]; !exists {
+ return credential.ErrGroupNotFound
+ }
+ store.groups[group.Name] = cloneGroup(group)
+ return nil
+}
diff --git a/weed/credential/memory/memory_store.go b/weed/credential/memory/memory_store.go
index e92fdf94d..baca350a8 100644
--- a/weed/credential/memory/memory_store.go
+++ b/weed/credential/memory/memory_store.go
@@ -23,6 +23,7 @@ type MemoryStore struct {
serviceAccounts map[string]*iam_pb.ServiceAccount // id -> service_account
serviceAccountAccessKeys map[string]string // access_key -> id
policies map[string]policy_engine.PolicyDocument // policy_name -> policy_document
+ groups map[string]*iam_pb.Group // group_name -> group
initialized bool
}
@@ -43,6 +44,7 @@ func (store *MemoryStore) Initialize(configuration util.Configuration, prefix st
store.serviceAccounts = make(map[string]*iam_pb.ServiceAccount)
store.serviceAccountAccessKeys = make(map[string]string)
store.policies = make(map[string]policy_engine.PolicyDocument)
+ store.groups = make(map[string]*iam_pb.Group)
store.initialized = true
return nil
@@ -57,6 +59,7 @@ func (store *MemoryStore) Shutdown() {
store.serviceAccounts = nil
store.serviceAccountAccessKeys = nil
store.policies = nil
+ store.groups = nil
store.initialized = false
}
@@ -71,6 +74,7 @@ func (store *MemoryStore) Reset() {
store.serviceAccounts = make(map[string]*iam_pb.ServiceAccount)
store.serviceAccountAccessKeys = make(map[string]string)
store.policies = make(map[string]policy_engine.PolicyDocument)
+ store.groups = make(map[string]*iam_pb.Group)
}
}
diff --git a/weed/credential/postgres/postgres_group.go b/weed/credential/postgres/postgres_group.go
new file mode 100644
index 000000000..dca8c6817
--- /dev/null
+++ b/weed/credential/postgres/postgres_group.go
@@ -0,0 +1,127 @@
+package postgres
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/seaweedfs/seaweedfs/weed/credential"
+ "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+)
+
+func (store *PostgresStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group == nil || group.Name == "" {
+ return fmt.Errorf("group name is required")
+ }
+ membersJSON, err := json.Marshal(group.Members)
+ if err != nil {
+ return fmt.Errorf("failed to marshal members: %w", err)
+ }
+ policyNamesJSON, err := json.Marshal(group.PolicyNames)
+ if err != nil {
+ return fmt.Errorf("failed to marshal policy_names: %w", err)
+ }
+
+ _, err = store.db.ExecContext(ctx,
+ `INSERT INTO groups (name, members, policy_names, disabled) VALUES ($1, $2, $3, $4)`,
+ group.Name, membersJSON, policyNamesJSON, group.Disabled)
+ if err != nil {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ return credential.ErrGroupAlreadyExists
+ }
+ return fmt.Errorf("failed to create group: %w", err)
+ }
+ return nil
+}
+
+func (store *PostgresStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
+ var membersJSON, policyNamesJSON []byte
+ var disabled bool
+ err := store.db.QueryRowContext(ctx,
+ `SELECT members, policy_names, disabled FROM groups WHERE name = $1`, groupName).
+ Scan(&membersJSON, &policyNamesJSON, &disabled)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, credential.ErrGroupNotFound
+ }
+ return nil, fmt.Errorf("failed to get group: %w", err)
+ }
+
+ group := &iam_pb.Group{
+ Name: groupName,
+ Disabled: disabled,
+ }
+ if err := json.Unmarshal(membersJSON, &group.Members); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal members: %w", err)
+ }
+ if err := json.Unmarshal(policyNamesJSON, &group.PolicyNames); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal policy_names: %w", err)
+ }
+ return group, nil
+}
+
+func (store *PostgresStore) DeleteGroup(ctx context.Context, groupName string) error {
+ result, err := store.db.ExecContext(ctx, `DELETE FROM groups WHERE name = $1`, groupName)
+ if err != nil {
+ return fmt.Errorf("failed to delete group: %w", err)
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+ if rows == 0 {
+ return credential.ErrGroupNotFound
+ }
+ return nil
+}
+
+func (store *PostgresStore) ListGroups(ctx context.Context) ([]string, error) {
+ rows, err := store.db.QueryContext(ctx, `SELECT name FROM groups ORDER BY name`)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list groups: %w", err)
+ }
+ defer rows.Close()
+
+ var names []string
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ return nil, fmt.Errorf("failed to scan group name: %w", err)
+ }
+ names = append(names, name)
+ }
+ return names, rows.Err()
+}
+
+func (store *PostgresStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group == nil || group.Name == "" {
+ return fmt.Errorf("group name is required")
+ }
+ membersJSON, err := json.Marshal(group.Members)
+ if err != nil {
+ return fmt.Errorf("failed to marshal members: %w", err)
+ }
+ policyNamesJSON, err := json.Marshal(group.PolicyNames)
+ if err != nil {
+ return fmt.Errorf("failed to marshal policy_names: %w", err)
+ }
+
+ result, err := store.db.ExecContext(ctx,
+ `UPDATE groups SET members = $1, policy_names = $2, disabled = $3, updated_at = CURRENT_TIMESTAMP WHERE name = $4`,
+ membersJSON, policyNamesJSON, group.Disabled, group.Name)
+ if err != nil {
+ return fmt.Errorf("failed to update group: %w", err)
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("failed to get rows affected: %w", err)
+ }
+ if rows == 0 {
+ return credential.ErrGroupNotFound
+ }
+ return nil
+}
diff --git a/weed/credential/postgres/postgres_store.go b/weed/credential/postgres/postgres_store.go
index 205e08ffa..3ba4810d6 100644
--- a/weed/credential/postgres/postgres_store.go
+++ b/weed/credential/postgres/postgres_store.go
@@ -140,6 +140,18 @@ func (store *PostgresStore) createTables() error {
);
`
+ // Create groups table
+ groupsTable := `
+ CREATE TABLE IF NOT EXISTS groups (
+ name VARCHAR(255) PRIMARY KEY,
+ members JSONB DEFAULT '[]',
+ policy_names JSONB DEFAULT '[]',
+ disabled BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ `
+
// Execute table creation
if _, err := store.db.Exec(usersTable); err != nil {
return fmt.Errorf("failed to create users table: %w", err)
@@ -162,6 +174,22 @@ func (store *PostgresStore) createTables() error {
return fmt.Errorf("failed to create service_accounts table: %w", err)
}
+ if _, err := store.db.Exec(groupsTable); err != nil {
+ return fmt.Errorf("failed to create groups table: %w", err)
+ }
+
+ // Create index on groups disabled column for filtering
+ groupsDisabledIndex := `CREATE INDEX IF NOT EXISTS idx_groups_disabled ON groups (disabled);`
+ if _, err := store.db.Exec(groupsDisabledIndex); err != nil {
+ return fmt.Errorf("failed to create groups disabled index: %w", err)
+ }
+
+ // Create GIN index on groups members JSONB for membership lookups
+ groupsMembersIndex := `CREATE INDEX IF NOT EXISTS idx_groups_members_gin ON groups USING GIN (members);`
+ if _, err := store.db.Exec(groupsMembersIndex); err != nil {
+ return fmt.Errorf("failed to create groups members index: %w", err)
+ }
+
return nil
}
diff --git a/weed/credential/propagating_store.go b/weed/credential/propagating_store.go
index d8fe615c9..f4facdf8f 100644
--- a/weed/credential/propagating_store.go
+++ b/weed/credential/propagating_store.go
@@ -385,3 +385,51 @@ func (s *PropagatingCredentialStore) DeleteServiceAccount(ctx context.Context, i
})
return nil
}
+
+func (s *PropagatingCredentialStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group != nil {
+ glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateGroup %s", group.Name)
+ }
+ if err := s.CredentialStore.CreateGroup(ctx, group); err != nil {
+ return err
+ }
+ s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
+ _, err := client.PutGroup(tx, &iam_pb.PutGroupRequest{Group: group})
+ return err
+ })
+ return nil
+}
+
+func (s *PropagatingCredentialStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
+ return s.CredentialStore.GetGroup(ctx, groupName)
+}
+
+func (s *PropagatingCredentialStore) DeleteGroup(ctx context.Context, groupName string) error {
+ glog.V(4).Infof("IAM: PropagatingCredentialStore.DeleteGroup %s", groupName)
+ if err := s.CredentialStore.DeleteGroup(ctx, groupName); err != nil {
+ return err
+ }
+ s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
+ _, err := client.RemoveGroup(tx, &iam_pb.RemoveGroupRequest{GroupName: groupName})
+ return err
+ })
+ return nil
+}
+
+func (s *PropagatingCredentialStore) ListGroups(ctx context.Context) ([]string, error) {
+ return s.CredentialStore.ListGroups(ctx)
+}
+
+func (s *PropagatingCredentialStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
+ if group != nil {
+ glog.V(4).Infof("IAM: PropagatingCredentialStore.UpdateGroup %s", group.Name)
+ }
+ if err := s.CredentialStore.UpdateGroup(ctx, group); err != nil {
+ return err
+ }
+ s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
+ _, err := client.PutGroup(tx, &iam_pb.PutGroupRequest{Group: group})
+ return err
+ })
+ return nil
+}
diff --git a/weed/iam/responses.go b/weed/iam/responses.go
index c64b3ce23..fd91e27f4 100644
--- a/weed/iam/responses.go
+++ b/weed/iam/responses.go
@@ -280,3 +280,93 @@ type UpdateServiceAccountResponse struct {
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateServiceAccountResponse"`
CommonResponse
}
+
+// CreateGroupResponse is the response for CreateGroup action.
+type CreateGroupResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateGroupResponse"`
+ CreateGroupResult struct {
+ Group iam.Group `xml:"Group"`
+ } `xml:"CreateGroupResult"`
+ CommonResponse
+}
+
+// DeleteGroupResponse is the response for DeleteGroup action.
+type DeleteGroupResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteGroupResponse"`
+ CommonResponse
+}
+
+// UpdateGroupResponse is the response for UpdateGroup action.
+type UpdateGroupResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateGroupResponse"`
+ CommonResponse
+}
+
+// GetGroupResponse is the response for GetGroup action.
+type GetGroupResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetGroupResponse"`
+ GetGroupResult struct {
+ Group iam.Group `xml:"Group"`
+ Users []*iam.User `xml:"Users>member"`
+ IsTruncated bool `xml:"IsTruncated"`
+ Marker string `xml:"Marker,omitempty"`
+ } `xml:"GetGroupResult"`
+ CommonResponse
+}
+
+// ListGroupsResponse is the response for ListGroups action.
+type ListGroupsResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsResponse"`
+ ListGroupsResult struct {
+ Groups []*iam.Group `xml:"Groups>member"`
+ IsTruncated bool `xml:"IsTruncated"`
+ Marker string `xml:"Marker,omitempty"`
+ } `xml:"ListGroupsResult"`
+ CommonResponse
+}
+
+// AddUserToGroupResponse is the response for AddUserToGroup action.
+type AddUserToGroupResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AddUserToGroupResponse"`
+ CommonResponse
+}
+
+// RemoveUserFromGroupResponse is the response for RemoveUserFromGroup action.
+type RemoveUserFromGroupResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ RemoveUserFromGroupResponse"`
+ CommonResponse
+}
+
+// AttachGroupPolicyResponse is the response for AttachGroupPolicy action.
+type AttachGroupPolicyResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AttachGroupPolicyResponse"`
+ CommonResponse
+}
+
+// DetachGroupPolicyResponse is the response for DetachGroupPolicy action.
+type DetachGroupPolicyResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DetachGroupPolicyResponse"`
+ CommonResponse
+}
+
+// ListAttachedGroupPoliciesResponse is the response for ListAttachedGroupPolicies action.
+type ListAttachedGroupPoliciesResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAttachedGroupPoliciesResponse"`
+ ListAttachedGroupPoliciesResult struct {
+ AttachedPolicies []*iam.AttachedPolicy `xml:"AttachedPolicies>member"`
+ IsTruncated bool `xml:"IsTruncated"`
+ Marker string `xml:"Marker,omitempty"`
+ } `xml:"ListAttachedGroupPoliciesResult"`
+ CommonResponse
+}
+
+// ListGroupsForUserResponse is the response for ListGroupsForUser action.
+type ListGroupsForUserResponse struct {
+ XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsForUserResponse"`
+ ListGroupsForUserResult struct {
+ Groups []*iam.Group `xml:"Groups>member"`
+ IsTruncated bool `xml:"IsTruncated"`
+ Marker string `xml:"Marker,omitempty"`
+ } `xml:"ListGroupsForUserResult"`
+ CommonResponse
+}
diff --git a/weed/iamapi/iamapi_group_handlers.go b/weed/iamapi/iamapi_group_handlers.go
new file mode 100644
index 000000000..b2a3545f6
--- /dev/null
+++ b/weed/iamapi/iamapi_group_handlers.go
@@ -0,0 +1,329 @@
+package iamapi
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+
+ "github.com/aws/aws-sdk-go/service/iam"
+ "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+ "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
+)
+
+func (iama *IamApiServer) CreateGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*CreateGroupResponse, *IamError) {
+ resp := &CreateGroupResponse{}
+ groupName := values.Get("GroupName")
+ if groupName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ return resp, &IamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("group %s already exists", groupName)}
+ }
+ }
+ s3cfg.Groups = append(s3cfg.Groups, &iam_pb.Group{Name: groupName})
+ resp.CreateGroupResult.Group.GroupName = &groupName
+ return resp, nil
+}
+
+func (iama *IamApiServer) DeleteGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*DeleteGroupResponse, *IamError) {
+ resp := &DeleteGroupResponse{}
+ groupName := values.Get("GroupName")
+ if groupName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ for i, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ if len(g.Members) > 0 {
+ return resp, &IamError{Code: iam.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete group %s: group has %d member(s)", groupName, len(g.Members))}
+ }
+ if len(g.PolicyNames) > 0 {
+ return resp, &IamError{Code: iam.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete group %s: group has %d attached policy(ies)", groupName, len(g.PolicyNames))}
+ }
+ s3cfg.Groups = append(s3cfg.Groups[:i], s3cfg.Groups[i+1:]...)
+ return resp, nil
+ }
+ }
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (iama *IamApiServer) UpdateGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*UpdateGroupResponse, *IamError) {
+ resp := &UpdateGroupResponse{}
+ groupName := values.Get("GroupName")
+ if groupName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ if disabled := values.Get("Disabled"); disabled != "" {
+ if disabled != "true" && disabled != "false" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("Disabled must be 'true' or 'false'")}
+ }
+ g.Disabled = disabled == "true"
+ }
+ if newName := values.Get("NewGroupName"); newName != "" && newName != g.Name {
+ for _, other := range s3cfg.Groups {
+ if other.Name == newName {
+ return resp, &IamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("group %s already exists", newName)}
+ }
+ }
+ g.Name = newName
+ }
+ return resp, nil
+ }
+ }
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (iama *IamApiServer) GetGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*GetGroupResponse, *IamError) {
+ resp := &GetGroupResponse{}
+ groupName := values.Get("GroupName")
+ if groupName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ resp.GetGroupResult.Group.GroupName = &g.Name
+ for _, member := range g.Members {
+ m := member
+ resp.GetGroupResult.Users = append(resp.GetGroupResult.Users, &iam.User{UserName: &m})
+ }
+ return resp, nil
+ }
+ }
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (iama *IamApiServer) ListGroups(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) *ListGroupsResponse {
+ resp := &ListGroupsResponse{}
+ for _, g := range s3cfg.Groups {
+ name := g.Name
+ resp.ListGroupsResult.Groups = append(resp.ListGroupsResult.Groups, &iam.Group{GroupName: &name})
+ }
+ return resp
+}
+
+func (iama *IamApiServer) AddUserToGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*AddUserToGroupResponse, *IamError) {
+ resp := &AddUserToGroupResponse{}
+ groupName := values.Get("GroupName")
+ userName := values.Get("UserName")
+ if groupName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ if userName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")}
+ }
+ userFound := false
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == userName {
+ userFound = true
+ break
+ }
+ }
+ if !userFound {
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s does not exist", userName)}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ for _, m := range g.Members {
+ if m == userName {
+ return resp, nil
+ }
+ }
+ g.Members = append(g.Members, userName)
+ return resp, nil
+ }
+ }
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (iama *IamApiServer) RemoveUserFromGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*RemoveUserFromGroupResponse, *IamError) {
+ resp := &RemoveUserFromGroupResponse{}
+ groupName := values.Get("GroupName")
+ userName := values.Get("UserName")
+ if groupName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ if userName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ for i, m := range g.Members {
+ if m == userName {
+ g.Members = append(g.Members[:i], g.Members[i+1:]...)
+ return resp, nil
+ }
+ }
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s is not a member of group %s", userName, groupName)}
+ }
+ }
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (iama *IamApiServer) AttachGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*AttachGroupPolicyResponse, *IamError) {
+ resp := &AttachGroupPolicyResponse{}
+ groupName := values.Get("GroupName")
+ policyArn := values.Get("PolicyArn")
+ if groupName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ policyName, iamErr := parsePolicyArn(policyArn)
+ if iamErr != nil {
+ return resp, iamErr
+ }
+ // Verify policy exists in the persisted policies store
+ policies := Policies{}
+ if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) {
+ return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr}
+ }
+ if _, exists := policies.Policies[policyName]; !exists {
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ for _, p := range g.PolicyNames {
+ if p == policyName {
+ return resp, nil
+ }
+ }
+ g.PolicyNames = append(g.PolicyNames, policyName)
+ return resp, nil
+ }
+ }
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (iama *IamApiServer) DetachGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*DetachGroupPolicyResponse, *IamError) {
+ resp := &DetachGroupPolicyResponse{}
+ groupName := values.Get("GroupName")
+ policyArn := values.Get("PolicyArn")
+ if groupName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ policyName, iamErr := parsePolicyArn(policyArn)
+ if iamErr != nil {
+ return resp, iamErr
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ for i, p := range g.PolicyNames {
+ if p == policyName {
+ g.PolicyNames = append(g.PolicyNames[:i], g.PolicyNames[i+1:]...)
+ return resp, nil
+ }
+ }
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s is not attached to group %s", policyName, groupName)}
+ }
+ }
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (iama *IamApiServer) ListAttachedGroupPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*ListAttachedGroupPoliciesResponse, *IamError) {
+ resp := &ListAttachedGroupPoliciesResponse{}
+ groupName := values.Get("GroupName")
+ if groupName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ for _, policyName := range g.PolicyNames {
+ pn := policyName
+ policyArn := policyArnPrefix + pn
+ resp.ListAttachedGroupPoliciesResult.AttachedPolicies = append(resp.ListAttachedGroupPoliciesResult.AttachedPolicies, &iam.AttachedPolicy{
+ PolicyName: &pn,
+ PolicyArn: &policyArn,
+ })
+ }
+ return resp, nil
+ }
+ }
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (iama *IamApiServer) ListGroupsForUser(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*ListGroupsForUserResponse, *IamError) {
+ resp := &ListGroupsForUserResponse{}
+ userName := values.Get("UserName")
+ if userName == "" {
+ return resp, &IamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")}
+ }
+ userFound := false
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == userName {
+ userFound = true
+ break
+ }
+ }
+ if !userFound {
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s does not exist", userName)}
+ }
+ // Build reverse index for efficient lookup
+ userGroupsIndex := buildUserGroupsIndex(s3cfg)
+ for _, gName := range userGroupsIndex[userName] {
+ name := gName
+ resp.ListGroupsForUserResult.Groups = append(resp.ListGroupsForUserResult.Groups, &iam.Group{GroupName: &name})
+ }
+ return resp, nil
+}
+
+// removeUserFromAllGroups removes a user from all groups they belong to.
+// Uses a reverse index for efficient lookup of which groups to modify.
+func removeUserFromAllGroups(s3cfg *iam_pb.S3ApiConfiguration, userName string) {
+ userGroupsIndex := buildUserGroupsIndex(s3cfg)
+ groupNames, found := userGroupsIndex[userName]
+ if !found {
+ return
+ }
+ // Build a set for fast group name lookup
+ targetGroups := make(map[string]bool, len(groupNames))
+ for _, gn := range groupNames {
+ targetGroups[gn] = true
+ }
+ for _, g := range s3cfg.Groups {
+ if !targetGroups[g.Name] {
+ continue
+ }
+ for i, m := range g.Members {
+ if m == userName {
+ g.Members = append(g.Members[:i], g.Members[i+1:]...)
+ break
+ }
+ }
+ }
+}
+
+// updateUserInGroups updates group membership references when a user is renamed.
+func updateUserInGroups(s3cfg *iam_pb.S3ApiConfiguration, oldUserName, newUserName string) {
+ for _, g := range s3cfg.Groups {
+ for i, m := range g.Members {
+ if m == oldUserName {
+ g.Members[i] = newUserName
+ break
+ }
+ }
+ }
+}
+
+// isPolicyAttachedToAnyGroup checks if a policy is attached to any group.
+func isPolicyAttachedToAnyGroup(s3cfg *iam_pb.S3ApiConfiguration, policyName string) (string, bool) {
+ for _, g := range s3cfg.Groups {
+ for _, p := range g.PolicyNames {
+ if p == policyName {
+ return g.Name, true
+ }
+ }
+ }
+ return "", false
+}
+
+// buildUserGroupsIndex builds a reverse index mapping usernames to group names.
+func buildUserGroupsIndex(s3cfg *iam_pb.S3ApiConfiguration) map[string][]string {
+ index := make(map[string][]string)
+ for _, g := range s3cfg.Groups {
+ for _, m := range g.Members {
+ index[m] = append(index[m], g.Name)
+ }
+ }
+ return index
+}
+
diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go
index c2141e6cd..d1d7487f8 100644
--- a/weed/iamapi/iamapi_management_handlers.go
+++ b/weed/iamapi/iamapi_management_handlers.go
@@ -219,6 +219,8 @@ func (iama *IamApiServer) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName
}
}
s3cfg.Identities = append(s3cfg.Identities[:i], s3cfg.Identities[i+1:]...)
+ // Remove user from all groups
+ removeUserFromAllGroups(s3cfg, userName)
return resp, nil
}
}
@@ -240,31 +242,74 @@ func (iama *IamApiServer) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values ur
resp = &UpdateUserResponse{}
userName := values.Get("UserName")
newUserName := values.Get("NewUserName")
- if newUserName != "" {
- for _, ident := range s3cfg.Identities {
- if userName == ident.Name {
- ident.Name = newUserName
- // Move any inline policies from old username to new username
- policies := Policies{}
- if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) {
- return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr}
- }
- if policies.InlinePolicies != nil {
- if userPolicies, exists := policies.InlinePolicies[userName]; exists {
- delete(policies.InlinePolicies, userName)
- policies.InlinePolicies[newUserName] = userPolicies
- if pErr := iama.s3ApiConfig.PutPolicies(&policies); pErr != nil {
- return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr}
- }
- }
- }
- return resp, nil
- }
+ if newUserName == "" {
+ return resp, nil
+ }
+
+ // Find the source identity first
+ var sourceIdent *iam_pb.Identity
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == userName {
+ sourceIdent = ident
+ break
}
- } else {
+ }
+ if sourceIdent == nil {
+ return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)}
+ }
+
+ // No-op if renaming to the same name
+ if newUserName == userName {
return resp, nil
}
- return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)}
+
+ // Check for name collision before renaming
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == newUserName {
+ return resp, &IamError{
+ Code: iam.ErrCodeEntityAlreadyExistsException,
+ Error: fmt.Errorf("user %s already exists", newUserName),
+ }
+ }
+ }
+ // Check for inline policy collision
+ policies := Policies{}
+ if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) {
+ return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr}
+ }
+ if policies.InlinePolicies != nil {
+ if _, exists := policies.InlinePolicies[newUserName]; exists {
+ return resp, &IamError{
+ Code: iam.ErrCodeEntityAlreadyExistsException,
+ Error: fmt.Errorf("inline policies already exist for user %s", newUserName),
+ }
+ }
+ }
+
+ sourceIdent.Name = newUserName
+ // Move any inline policies from old username to new username
+ if policies.InlinePolicies != nil {
+ if userPolicies, exists := policies.InlinePolicies[userName]; exists {
+ delete(policies.InlinePolicies, userName)
+ policies.InlinePolicies[newUserName] = userPolicies
+ if pErr := iama.s3ApiConfig.PutPolicies(&policies); pErr != nil {
+ // Rollback: restore identity name and inline policies
+ sourceIdent.Name = userName
+ delete(policies.InlinePolicies, newUserName)
+ policies.InlinePolicies[userName] = userPolicies
+ return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr}
+ }
+ }
+ }
+ // Update group membership references
+ updateUserInGroups(s3cfg, userName, newUserName)
+ // Update service account parent references
+ for _, sa := range s3cfg.ServiceAccounts {
+ if sa.ParentUser == userName {
+ sa.ParentUser = newUserName
+ }
+ }
+ return resp, nil
}
func GetPolicyDocument(policy *string) (policy_engine.PolicyDocument, error) {
@@ -540,6 +585,14 @@ func (iama *IamApiServer) DeletePolicy(s3cfg *iam_pb.S3ApiConfiguration, values
}
}
+ // Reject deletion if the policy is attached to any group
+ if groupName, attached := isPolicyAttachedToAnyGroup(s3cfg, policyName); attached {
+ return resp, &IamError{
+ Code: iam.ErrCodeDeleteConflictException,
+ Error: fmt.Errorf("policy %s is still attached to group %s", policyName, groupName),
+ }
+ }
+
delete(policies.Policies, policyName)
if err := iama.s3ApiConfig.PutPolicies(&policies); err != nil {
return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err}
@@ -1055,6 +1108,83 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) {
return
}
changed = false
+ // Group actions
+ case "CreateGroup":
+ var err *IamError
+ response, err = iama.CreateGroup(s3cfg, values)
+ if err != nil {
+ writeIamErrorResponse(w, r, reqID, err)
+ return
+ }
+ case "DeleteGroup":
+ var err *IamError
+ response, err = iama.DeleteGroup(s3cfg, values)
+ if err != nil {
+ writeIamErrorResponse(w, r, reqID, err)
+ return
+ }
+ case "UpdateGroup":
+ var err *IamError
+ response, err = iama.UpdateGroup(s3cfg, values)
+ if err != nil {
+ writeIamErrorResponse(w, r, reqID, err)
+ return
+ }
+ case "GetGroup":
+ var err *IamError
+ response, err = iama.GetGroup(s3cfg, values)
+ if err != nil {
+ writeIamErrorResponse(w, r, reqID, err)
+ return
+ }
+ changed = false
+ case "ListGroups":
+ response = iama.ListGroups(s3cfg, values)
+ changed = false
+ case "AddUserToGroup":
+ var err *IamError
+ response, err = iama.AddUserToGroup(s3cfg, values)
+ if err != nil {
+ writeIamErrorResponse(w, r, reqID, err)
+ return
+ }
+ case "RemoveUserFromGroup":
+ var err *IamError
+ response, err = iama.RemoveUserFromGroup(s3cfg, values)
+ if err != nil {
+ writeIamErrorResponse(w, r, reqID, err)
+ return
+ }
+ case "AttachGroupPolicy":
+ var err *IamError
+ response, err = iama.AttachGroupPolicy(s3cfg, values)
+ if err != nil {
+ writeIamErrorResponse(w, r, reqID, err)
+ return
+ }
+ case "DetachGroupPolicy":
+ var err *IamError
+ response, err = iama.DetachGroupPolicy(s3cfg, values)
+ if err != nil {
+ writeIamErrorResponse(w, r, reqID, err)
+ return
+ }
+ case "ListAttachedGroupPolicies":
+ var err *IamError
+ response, err = iama.ListAttachedGroupPolicies(s3cfg, values)
+ if err != nil {
+ writeIamErrorResponse(w, r, reqID, err)
+ return
+ }
+ changed = false
+ case "ListGroupsForUser":
+ var err *IamError
+ response, err = iama.ListGroupsForUser(s3cfg, values)
+ if err != nil {
+ writeIamErrorResponse(w, r, reqID, err)
+ return
+ }
+ changed = false
default:
errNotImplemented := s3err.GetAPIError(s3err.ErrNotImplemented)
errorResponse := newErrorResponse(errNotImplemented.Code, errNotImplemented.Description, reqID)
diff --git a/weed/iamapi/iamapi_response.go b/weed/iamapi/iamapi_response.go
index ea596655d..56fcb1be1 100644
--- a/weed/iamapi/iamapi_response.go
+++ b/weed/iamapi/iamapi_response.go
@@ -36,4 +36,16 @@ type (
ListServiceAccountsResponse = iamlib.ListServiceAccountsResponse
GetServiceAccountResponse = iamlib.GetServiceAccountResponse
UpdateServiceAccountResponse = iamlib.UpdateServiceAccountResponse
+ // Group response types
+ CreateGroupResponse = iamlib.CreateGroupResponse
+ DeleteGroupResponse = iamlib.DeleteGroupResponse
+ UpdateGroupResponse = iamlib.UpdateGroupResponse
+ GetGroupResponse = iamlib.GetGroupResponse
+ ListGroupsResponse = iamlib.ListGroupsResponse
+ AddUserToGroupResponse = iamlib.AddUserToGroupResponse
+ RemoveUserFromGroupResponse = iamlib.RemoveUserFromGroupResponse
+ AttachGroupPolicyResponse = iamlib.AttachGroupPolicyResponse
+ DetachGroupPolicyResponse = iamlib.DetachGroupPolicyResponse
+ ListAttachedGroupPoliciesResponse = iamlib.ListAttachedGroupPoliciesResponse
+ ListGroupsForUserResponse = iamlib.ListGroupsForUserResponse
)
diff --git a/weed/pb/iam.proto b/weed/pb/iam.proto
index 4972ae108..05b575ce8 100644
--- a/weed/pb/iam.proto
+++ b/weed/pb/iam.proto
@@ -168,6 +168,14 @@ message S3ApiConfiguration {
repeated Account accounts = 2;
repeated ServiceAccount service_accounts = 3;
repeated Policy policies = 4;
+ repeated Group groups = 5;
+}
+
+message Group {
+ string name = 1;
+ repeated string members = 2; // usernames
+ repeated string policy_names = 3; // attached managed policy names
+ bool disabled = 4;
}
message Identity {
@@ -309,3 +317,17 @@ message RemoveIdentityRequest {
message RemoveIdentityResponse {
}
+
+message PutGroupRequest {
+ Group group = 1;
+}
+
+message PutGroupResponse {
+}
+
+message RemoveGroupRequest {
+ string group_name = 1;
+}
+
+message RemoveGroupResponse {
+}
diff --git a/weed/pb/iam_pb/iam.pb.go b/weed/pb/iam_pb/iam.pb.go
index 243abda7e..9cbf29ea8 100644
--- a/weed/pb/iam_pb/iam.pb.go
+++ b/weed/pb/iam_pb/iam.pb.go
@@ -1252,6 +1252,7 @@ type S3ApiConfiguration struct {
Accounts []*Account `protobuf:"bytes,2,rep,name=accounts,proto3" json:"accounts,omitempty"`
ServiceAccounts []*ServiceAccount `protobuf:"bytes,3,rep,name=service_accounts,json=serviceAccounts,proto3" json:"service_accounts,omitempty"`
Policies []*Policy `protobuf:"bytes,4,rep,name=policies,proto3" json:"policies,omitempty"`
+ Groups []*Group `protobuf:"bytes,5,rep,name=groups,proto3" json:"groups,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1314,6 +1315,81 @@ func (x *S3ApiConfiguration) GetPolicies() []*Policy {
return nil
}
+func (x *S3ApiConfiguration) GetGroups() []*Group {
+ if x != nil {
+ return x.Groups
+ }
+ return nil
+}
+
+type Group struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+ Members []string `protobuf:"bytes,2,rep,name=members,proto3" json:"members,omitempty"` // usernames
+ PolicyNames []string `protobuf:"bytes,3,rep,name=policy_names,json=policyNames,proto3" json:"policy_names,omitempty"` // attached managed policy names
+ Disabled bool `protobuf:"varint,4,opt,name=disabled,proto3" json:"disabled,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *Group) Reset() {
+ *x = Group{}
+ mi := &file_iam_proto_msgTypes[29]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *Group) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Group) ProtoMessage() {}
+
+func (x *Group) ProtoReflect() protoreflect.Message {
+ mi := &file_iam_proto_msgTypes[29]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use Group.ProtoReflect.Descriptor instead.
+func (*Group) Descriptor() ([]byte, []int) {
+ return file_iam_proto_rawDescGZIP(), []int{29}
+}
+
+func (x *Group) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *Group) GetMembers() []string {
+ if x != nil {
+ return x.Members
+ }
+ return nil
+}
+
+func (x *Group) GetPolicyNames() []string {
+ if x != nil {
+ return x.PolicyNames
+ }
+ return nil
+}
+
+func (x *Group) GetDisabled() bool {
+ if x != nil {
+ return x.Disabled
+ }
+ return false
+}
+
type Identity struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
@@ -1329,7 +1405,7 @@ type Identity struct {
func (x *Identity) Reset() {
*x = Identity{}
- mi := &file_iam_proto_msgTypes[29]
+ mi := &file_iam_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1341,7 +1417,7 @@ func (x *Identity) String() string {
func (*Identity) ProtoMessage() {}
func (x *Identity) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[29]
+ mi := &file_iam_proto_msgTypes[30]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1354,7 +1430,7 @@ func (x *Identity) ProtoReflect() protoreflect.Message {
// Deprecated: Use Identity.ProtoReflect.Descriptor instead.
func (*Identity) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{29}
+ return file_iam_proto_rawDescGZIP(), []int{30}
}
func (x *Identity) GetName() string {
@@ -1417,7 +1493,7 @@ type Credential struct {
func (x *Credential) Reset() {
*x = Credential{}
- mi := &file_iam_proto_msgTypes[30]
+ mi := &file_iam_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1429,7 +1505,7 @@ func (x *Credential) String() string {
func (*Credential) ProtoMessage() {}
func (x *Credential) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[30]
+ mi := &file_iam_proto_msgTypes[31]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1442,7 +1518,7 @@ func (x *Credential) ProtoReflect() protoreflect.Message {
// Deprecated: Use Credential.ProtoReflect.Descriptor instead.
func (*Credential) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{30}
+ return file_iam_proto_rawDescGZIP(), []int{31}
}
func (x *Credential) GetAccessKey() string {
@@ -1477,7 +1553,7 @@ type Account struct {
func (x *Account) Reset() {
*x = Account{}
- mi := &file_iam_proto_msgTypes[31]
+ mi := &file_iam_proto_msgTypes[32]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1489,7 +1565,7 @@ func (x *Account) String() string {
func (*Account) ProtoMessage() {}
func (x *Account) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[31]
+ mi := &file_iam_proto_msgTypes[32]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1502,7 +1578,7 @@ func (x *Account) ProtoReflect() protoreflect.Message {
// Deprecated: Use Account.ProtoReflect.Descriptor instead.
func (*Account) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{31}
+ return file_iam_proto_rawDescGZIP(), []int{32}
}
func (x *Account) GetId() string {
@@ -1545,7 +1621,7 @@ type ServiceAccount struct {
func (x *ServiceAccount) Reset() {
*x = ServiceAccount{}
- mi := &file_iam_proto_msgTypes[32]
+ mi := &file_iam_proto_msgTypes[33]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1557,7 +1633,7 @@ func (x *ServiceAccount) String() string {
func (*ServiceAccount) ProtoMessage() {}
func (x *ServiceAccount) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[32]
+ mi := &file_iam_proto_msgTypes[33]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1570,7 +1646,7 @@ func (x *ServiceAccount) ProtoReflect() protoreflect.Message {
// Deprecated: Use ServiceAccount.ProtoReflect.Descriptor instead.
func (*ServiceAccount) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{32}
+ return file_iam_proto_rawDescGZIP(), []int{33}
}
func (x *ServiceAccount) GetId() string {
@@ -1646,7 +1722,7 @@ type PutPolicyRequest struct {
func (x *PutPolicyRequest) Reset() {
*x = PutPolicyRequest{}
- mi := &file_iam_proto_msgTypes[33]
+ mi := &file_iam_proto_msgTypes[34]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1658,7 +1734,7 @@ func (x *PutPolicyRequest) String() string {
func (*PutPolicyRequest) ProtoMessage() {}
func (x *PutPolicyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[33]
+ mi := &file_iam_proto_msgTypes[34]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1671,7 +1747,7 @@ func (x *PutPolicyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PutPolicyRequest.ProtoReflect.Descriptor instead.
func (*PutPolicyRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{33}
+ return file_iam_proto_rawDescGZIP(), []int{34}
}
func (x *PutPolicyRequest) GetName() string {
@@ -1696,7 +1772,7 @@ type PutPolicyResponse struct {
func (x *PutPolicyResponse) Reset() {
*x = PutPolicyResponse{}
- mi := &file_iam_proto_msgTypes[34]
+ mi := &file_iam_proto_msgTypes[35]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1708,7 +1784,7 @@ func (x *PutPolicyResponse) String() string {
func (*PutPolicyResponse) ProtoMessage() {}
func (x *PutPolicyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[34]
+ mi := &file_iam_proto_msgTypes[35]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1721,7 +1797,7 @@ func (x *PutPolicyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use PutPolicyResponse.ProtoReflect.Descriptor instead.
func (*PutPolicyResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{34}
+ return file_iam_proto_rawDescGZIP(), []int{35}
}
type GetPolicyRequest struct {
@@ -1733,7 +1809,7 @@ type GetPolicyRequest struct {
func (x *GetPolicyRequest) Reset() {
*x = GetPolicyRequest{}
- mi := &file_iam_proto_msgTypes[35]
+ mi := &file_iam_proto_msgTypes[36]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1745,7 +1821,7 @@ func (x *GetPolicyRequest) String() string {
func (*GetPolicyRequest) ProtoMessage() {}
func (x *GetPolicyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[35]
+ mi := &file_iam_proto_msgTypes[36]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1758,7 +1834,7 @@ func (x *GetPolicyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPolicyRequest.ProtoReflect.Descriptor instead.
func (*GetPolicyRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{35}
+ return file_iam_proto_rawDescGZIP(), []int{36}
}
func (x *GetPolicyRequest) GetName() string {
@@ -1778,7 +1854,7 @@ type GetPolicyResponse struct {
func (x *GetPolicyResponse) Reset() {
*x = GetPolicyResponse{}
- mi := &file_iam_proto_msgTypes[36]
+ mi := &file_iam_proto_msgTypes[37]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1790,7 +1866,7 @@ func (x *GetPolicyResponse) String() string {
func (*GetPolicyResponse) ProtoMessage() {}
func (x *GetPolicyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[36]
+ mi := &file_iam_proto_msgTypes[37]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1803,7 +1879,7 @@ func (x *GetPolicyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPolicyResponse.ProtoReflect.Descriptor instead.
func (*GetPolicyResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{36}
+ return file_iam_proto_rawDescGZIP(), []int{37}
}
func (x *GetPolicyResponse) GetName() string {
@@ -1828,7 +1904,7 @@ type ListPoliciesRequest struct {
func (x *ListPoliciesRequest) Reset() {
*x = ListPoliciesRequest{}
- mi := &file_iam_proto_msgTypes[37]
+ mi := &file_iam_proto_msgTypes[38]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1840,7 +1916,7 @@ func (x *ListPoliciesRequest) String() string {
func (*ListPoliciesRequest) ProtoMessage() {}
func (x *ListPoliciesRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[37]
+ mi := &file_iam_proto_msgTypes[38]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1853,7 +1929,7 @@ func (x *ListPoliciesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListPoliciesRequest.ProtoReflect.Descriptor instead.
func (*ListPoliciesRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{37}
+ return file_iam_proto_rawDescGZIP(), []int{38}
}
type ListPoliciesResponse struct {
@@ -1865,7 +1941,7 @@ type ListPoliciesResponse struct {
func (x *ListPoliciesResponse) Reset() {
*x = ListPoliciesResponse{}
- mi := &file_iam_proto_msgTypes[38]
+ mi := &file_iam_proto_msgTypes[39]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1877,7 +1953,7 @@ func (x *ListPoliciesResponse) String() string {
func (*ListPoliciesResponse) ProtoMessage() {}
func (x *ListPoliciesResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[38]
+ mi := &file_iam_proto_msgTypes[39]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1890,7 +1966,7 @@ func (x *ListPoliciesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListPoliciesResponse.ProtoReflect.Descriptor instead.
func (*ListPoliciesResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{38}
+ return file_iam_proto_rawDescGZIP(), []int{39}
}
func (x *ListPoliciesResponse) GetPolicies() []*Policy {
@@ -1909,7 +1985,7 @@ type DeletePolicyRequest struct {
func (x *DeletePolicyRequest) Reset() {
*x = DeletePolicyRequest{}
- mi := &file_iam_proto_msgTypes[39]
+ mi := &file_iam_proto_msgTypes[40]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1921,7 +1997,7 @@ func (x *DeletePolicyRequest) String() string {
func (*DeletePolicyRequest) ProtoMessage() {}
func (x *DeletePolicyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[39]
+ mi := &file_iam_proto_msgTypes[40]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1934,7 +2010,7 @@ func (x *DeletePolicyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeletePolicyRequest.ProtoReflect.Descriptor instead.
func (*DeletePolicyRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{39}
+ return file_iam_proto_rawDescGZIP(), []int{40}
}
func (x *DeletePolicyRequest) GetName() string {
@@ -1952,7 +2028,7 @@ type DeletePolicyResponse struct {
func (x *DeletePolicyResponse) Reset() {
*x = DeletePolicyResponse{}
- mi := &file_iam_proto_msgTypes[40]
+ mi := &file_iam_proto_msgTypes[41]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1964,7 +2040,7 @@ func (x *DeletePolicyResponse) String() string {
func (*DeletePolicyResponse) ProtoMessage() {}
func (x *DeletePolicyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[40]
+ mi := &file_iam_proto_msgTypes[41]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1977,7 +2053,7 @@ func (x *DeletePolicyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeletePolicyResponse.ProtoReflect.Descriptor instead.
func (*DeletePolicyResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{40}
+ return file_iam_proto_rawDescGZIP(), []int{41}
}
type Policy struct {
@@ -1990,7 +2066,7 @@ type Policy struct {
func (x *Policy) Reset() {
*x = Policy{}
- mi := &file_iam_proto_msgTypes[41]
+ mi := &file_iam_proto_msgTypes[42]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2002,7 +2078,7 @@ func (x *Policy) String() string {
func (*Policy) ProtoMessage() {}
func (x *Policy) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[41]
+ mi := &file_iam_proto_msgTypes[42]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2015,7 +2091,7 @@ func (x *Policy) ProtoReflect() protoreflect.Message {
// Deprecated: Use Policy.ProtoReflect.Descriptor instead.
func (*Policy) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{41}
+ return file_iam_proto_rawDescGZIP(), []int{42}
}
func (x *Policy) GetName() string {
@@ -2041,7 +2117,7 @@ type CreateServiceAccountRequest struct {
func (x *CreateServiceAccountRequest) Reset() {
*x = CreateServiceAccountRequest{}
- mi := &file_iam_proto_msgTypes[42]
+ mi := &file_iam_proto_msgTypes[43]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2053,7 +2129,7 @@ func (x *CreateServiceAccountRequest) String() string {
func (*CreateServiceAccountRequest) ProtoMessage() {}
func (x *CreateServiceAccountRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[42]
+ mi := &file_iam_proto_msgTypes[43]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2066,7 +2142,7 @@ func (x *CreateServiceAccountRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateServiceAccountRequest.ProtoReflect.Descriptor instead.
func (*CreateServiceAccountRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{42}
+ return file_iam_proto_rawDescGZIP(), []int{43}
}
func (x *CreateServiceAccountRequest) GetServiceAccount() *ServiceAccount {
@@ -2084,7 +2160,7 @@ type CreateServiceAccountResponse struct {
func (x *CreateServiceAccountResponse) Reset() {
*x = CreateServiceAccountResponse{}
- mi := &file_iam_proto_msgTypes[43]
+ mi := &file_iam_proto_msgTypes[44]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2096,7 +2172,7 @@ func (x *CreateServiceAccountResponse) String() string {
func (*CreateServiceAccountResponse) ProtoMessage() {}
func (x *CreateServiceAccountResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[43]
+ mi := &file_iam_proto_msgTypes[44]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2109,7 +2185,7 @@ func (x *CreateServiceAccountResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateServiceAccountResponse.ProtoReflect.Descriptor instead.
func (*CreateServiceAccountResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{43}
+ return file_iam_proto_rawDescGZIP(), []int{44}
}
type UpdateServiceAccountRequest struct {
@@ -2122,7 +2198,7 @@ type UpdateServiceAccountRequest struct {
func (x *UpdateServiceAccountRequest) Reset() {
*x = UpdateServiceAccountRequest{}
- mi := &file_iam_proto_msgTypes[44]
+ mi := &file_iam_proto_msgTypes[45]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2134,7 +2210,7 @@ func (x *UpdateServiceAccountRequest) String() string {
func (*UpdateServiceAccountRequest) ProtoMessage() {}
func (x *UpdateServiceAccountRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[44]
+ mi := &file_iam_proto_msgTypes[45]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2147,7 +2223,7 @@ func (x *UpdateServiceAccountRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use UpdateServiceAccountRequest.ProtoReflect.Descriptor instead.
func (*UpdateServiceAccountRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{44}
+ return file_iam_proto_rawDescGZIP(), []int{45}
}
func (x *UpdateServiceAccountRequest) GetId() string {
@@ -2172,7 +2248,7 @@ type UpdateServiceAccountResponse struct {
func (x *UpdateServiceAccountResponse) Reset() {
*x = UpdateServiceAccountResponse{}
- mi := &file_iam_proto_msgTypes[45]
+ mi := &file_iam_proto_msgTypes[46]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2184,7 +2260,7 @@ func (x *UpdateServiceAccountResponse) String() string {
func (*UpdateServiceAccountResponse) ProtoMessage() {}
func (x *UpdateServiceAccountResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[45]
+ mi := &file_iam_proto_msgTypes[46]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2197,7 +2273,7 @@ func (x *UpdateServiceAccountResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use UpdateServiceAccountResponse.ProtoReflect.Descriptor instead.
func (*UpdateServiceAccountResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{45}
+ return file_iam_proto_rawDescGZIP(), []int{46}
}
type DeleteServiceAccountRequest struct {
@@ -2209,7 +2285,7 @@ type DeleteServiceAccountRequest struct {
func (x *DeleteServiceAccountRequest) Reset() {
*x = DeleteServiceAccountRequest{}
- mi := &file_iam_proto_msgTypes[46]
+ mi := &file_iam_proto_msgTypes[47]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2221,7 +2297,7 @@ func (x *DeleteServiceAccountRequest) String() string {
func (*DeleteServiceAccountRequest) ProtoMessage() {}
func (x *DeleteServiceAccountRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[46]
+ mi := &file_iam_proto_msgTypes[47]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2234,7 +2310,7 @@ func (x *DeleteServiceAccountRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteServiceAccountRequest.ProtoReflect.Descriptor instead.
func (*DeleteServiceAccountRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{46}
+ return file_iam_proto_rawDescGZIP(), []int{47}
}
func (x *DeleteServiceAccountRequest) GetId() string {
@@ -2252,7 +2328,7 @@ type DeleteServiceAccountResponse struct {
func (x *DeleteServiceAccountResponse) Reset() {
*x = DeleteServiceAccountResponse{}
- mi := &file_iam_proto_msgTypes[47]
+ mi := &file_iam_proto_msgTypes[48]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2264,7 +2340,7 @@ func (x *DeleteServiceAccountResponse) String() string {
func (*DeleteServiceAccountResponse) ProtoMessage() {}
func (x *DeleteServiceAccountResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[47]
+ mi := &file_iam_proto_msgTypes[48]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2277,7 +2353,7 @@ func (x *DeleteServiceAccountResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteServiceAccountResponse.ProtoReflect.Descriptor instead.
func (*DeleteServiceAccountResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{47}
+ return file_iam_proto_rawDescGZIP(), []int{48}
}
type GetServiceAccountRequest struct {
@@ -2289,7 +2365,7 @@ type GetServiceAccountRequest struct {
func (x *GetServiceAccountRequest) Reset() {
*x = GetServiceAccountRequest{}
- mi := &file_iam_proto_msgTypes[48]
+ mi := &file_iam_proto_msgTypes[49]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2301,7 +2377,7 @@ func (x *GetServiceAccountRequest) String() string {
func (*GetServiceAccountRequest) ProtoMessage() {}
func (x *GetServiceAccountRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[48]
+ mi := &file_iam_proto_msgTypes[49]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2314,7 +2390,7 @@ func (x *GetServiceAccountRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetServiceAccountRequest.ProtoReflect.Descriptor instead.
func (*GetServiceAccountRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{48}
+ return file_iam_proto_rawDescGZIP(), []int{49}
}
func (x *GetServiceAccountRequest) GetId() string {
@@ -2333,7 +2409,7 @@ type GetServiceAccountResponse struct {
func (x *GetServiceAccountResponse) Reset() {
*x = GetServiceAccountResponse{}
- mi := &file_iam_proto_msgTypes[49]
+ mi := &file_iam_proto_msgTypes[50]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2345,7 +2421,7 @@ func (x *GetServiceAccountResponse) String() string {
func (*GetServiceAccountResponse) ProtoMessage() {}
func (x *GetServiceAccountResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[49]
+ mi := &file_iam_proto_msgTypes[50]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2358,7 +2434,7 @@ func (x *GetServiceAccountResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetServiceAccountResponse.ProtoReflect.Descriptor instead.
func (*GetServiceAccountResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{49}
+ return file_iam_proto_rawDescGZIP(), []int{50}
}
func (x *GetServiceAccountResponse) GetServiceAccount() *ServiceAccount {
@@ -2376,7 +2452,7 @@ type ListServiceAccountsRequest struct {
func (x *ListServiceAccountsRequest) Reset() {
*x = ListServiceAccountsRequest{}
- mi := &file_iam_proto_msgTypes[50]
+ mi := &file_iam_proto_msgTypes[51]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2388,7 +2464,7 @@ func (x *ListServiceAccountsRequest) String() string {
func (*ListServiceAccountsRequest) ProtoMessage() {}
func (x *ListServiceAccountsRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[50]
+ mi := &file_iam_proto_msgTypes[51]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2401,7 +2477,7 @@ func (x *ListServiceAccountsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListServiceAccountsRequest.ProtoReflect.Descriptor instead.
func (*ListServiceAccountsRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{50}
+ return file_iam_proto_rawDescGZIP(), []int{51}
}
type ListServiceAccountsResponse struct {
@@ -2413,7 +2489,7 @@ type ListServiceAccountsResponse struct {
func (x *ListServiceAccountsResponse) Reset() {
*x = ListServiceAccountsResponse{}
- mi := &file_iam_proto_msgTypes[51]
+ mi := &file_iam_proto_msgTypes[52]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2425,7 +2501,7 @@ func (x *ListServiceAccountsResponse) String() string {
func (*ListServiceAccountsResponse) ProtoMessage() {}
func (x *ListServiceAccountsResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[51]
+ mi := &file_iam_proto_msgTypes[52]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2438,7 +2514,7 @@ func (x *ListServiceAccountsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListServiceAccountsResponse.ProtoReflect.Descriptor instead.
func (*ListServiceAccountsResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{51}
+ return file_iam_proto_rawDescGZIP(), []int{52}
}
func (x *ListServiceAccountsResponse) GetServiceAccounts() []*ServiceAccount {
@@ -2457,7 +2533,7 @@ type GetServiceAccountByAccessKeyRequest struct {
func (x *GetServiceAccountByAccessKeyRequest) Reset() {
*x = GetServiceAccountByAccessKeyRequest{}
- mi := &file_iam_proto_msgTypes[52]
+ mi := &file_iam_proto_msgTypes[53]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2469,7 +2545,7 @@ func (x *GetServiceAccountByAccessKeyRequest) String() string {
func (*GetServiceAccountByAccessKeyRequest) ProtoMessage() {}
func (x *GetServiceAccountByAccessKeyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[52]
+ mi := &file_iam_proto_msgTypes[53]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2482,7 +2558,7 @@ func (x *GetServiceAccountByAccessKeyRequest) ProtoReflect() protoreflect.Messag
// Deprecated: Use GetServiceAccountByAccessKeyRequest.ProtoReflect.Descriptor instead.
func (*GetServiceAccountByAccessKeyRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{52}
+ return file_iam_proto_rawDescGZIP(), []int{53}
}
func (x *GetServiceAccountByAccessKeyRequest) GetAccessKey() string {
@@ -2501,7 +2577,7 @@ type GetServiceAccountByAccessKeyResponse struct {
func (x *GetServiceAccountByAccessKeyResponse) Reset() {
*x = GetServiceAccountByAccessKeyResponse{}
- mi := &file_iam_proto_msgTypes[53]
+ mi := &file_iam_proto_msgTypes[54]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2513,7 +2589,7 @@ func (x *GetServiceAccountByAccessKeyResponse) String() string {
func (*GetServiceAccountByAccessKeyResponse) ProtoMessage() {}
func (x *GetServiceAccountByAccessKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[53]
+ mi := &file_iam_proto_msgTypes[54]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2526,7 +2602,7 @@ func (x *GetServiceAccountByAccessKeyResponse) ProtoReflect() protoreflect.Messa
// Deprecated: Use GetServiceAccountByAccessKeyResponse.ProtoReflect.Descriptor instead.
func (*GetServiceAccountByAccessKeyResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{53}
+ return file_iam_proto_rawDescGZIP(), []int{54}
}
func (x *GetServiceAccountByAccessKeyResponse) GetServiceAccount() *ServiceAccount {
@@ -2545,7 +2621,7 @@ type PutIdentityRequest struct {
func (x *PutIdentityRequest) Reset() {
*x = PutIdentityRequest{}
- mi := &file_iam_proto_msgTypes[54]
+ mi := &file_iam_proto_msgTypes[55]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2557,7 +2633,7 @@ func (x *PutIdentityRequest) String() string {
func (*PutIdentityRequest) ProtoMessage() {}
func (x *PutIdentityRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[54]
+ mi := &file_iam_proto_msgTypes[55]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2570,7 +2646,7 @@ func (x *PutIdentityRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PutIdentityRequest.ProtoReflect.Descriptor instead.
func (*PutIdentityRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{54}
+ return file_iam_proto_rawDescGZIP(), []int{55}
}
func (x *PutIdentityRequest) GetIdentity() *Identity {
@@ -2588,7 +2664,7 @@ type PutIdentityResponse struct {
func (x *PutIdentityResponse) Reset() {
*x = PutIdentityResponse{}
- mi := &file_iam_proto_msgTypes[55]
+ mi := &file_iam_proto_msgTypes[56]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2600,7 +2676,7 @@ func (x *PutIdentityResponse) String() string {
func (*PutIdentityResponse) ProtoMessage() {}
func (x *PutIdentityResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[55]
+ mi := &file_iam_proto_msgTypes[56]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2613,7 +2689,7 @@ func (x *PutIdentityResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use PutIdentityResponse.ProtoReflect.Descriptor instead.
func (*PutIdentityResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{55}
+ return file_iam_proto_rawDescGZIP(), []int{56}
}
type RemoveIdentityRequest struct {
@@ -2625,7 +2701,7 @@ type RemoveIdentityRequest struct {
func (x *RemoveIdentityRequest) Reset() {
*x = RemoveIdentityRequest{}
- mi := &file_iam_proto_msgTypes[56]
+ mi := &file_iam_proto_msgTypes[57]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2637,7 +2713,7 @@ func (x *RemoveIdentityRequest) String() string {
func (*RemoveIdentityRequest) ProtoMessage() {}
func (x *RemoveIdentityRequest) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[56]
+ mi := &file_iam_proto_msgTypes[57]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2650,7 +2726,7 @@ func (x *RemoveIdentityRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveIdentityRequest.ProtoReflect.Descriptor instead.
func (*RemoveIdentityRequest) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{56}
+ return file_iam_proto_rawDescGZIP(), []int{57}
}
func (x *RemoveIdentityRequest) GetUsername() string {
@@ -2668,7 +2744,7 @@ type RemoveIdentityResponse struct {
func (x *RemoveIdentityResponse) Reset() {
*x = RemoveIdentityResponse{}
- mi := &file_iam_proto_msgTypes[57]
+ mi := &file_iam_proto_msgTypes[58]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2680,7 +2756,7 @@ func (x *RemoveIdentityResponse) String() string {
func (*RemoveIdentityResponse) ProtoMessage() {}
func (x *RemoveIdentityResponse) ProtoReflect() protoreflect.Message {
- mi := &file_iam_proto_msgTypes[57]
+ mi := &file_iam_proto_msgTypes[58]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2693,7 +2769,167 @@ func (x *RemoveIdentityResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveIdentityResponse.ProtoReflect.Descriptor instead.
func (*RemoveIdentityResponse) Descriptor() ([]byte, []int) {
- return file_iam_proto_rawDescGZIP(), []int{57}
+ return file_iam_proto_rawDescGZIP(), []int{58}
+}
+
+type PutGroupRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Group *Group `protobuf:"bytes,1,opt,name=group,proto3" json:"group,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *PutGroupRequest) Reset() {
+ *x = PutGroupRequest{}
+ mi := &file_iam_proto_msgTypes[59]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *PutGroupRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PutGroupRequest) ProtoMessage() {}
+
+func (x *PutGroupRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_iam_proto_msgTypes[59]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use PutGroupRequest.ProtoReflect.Descriptor instead.
+func (*PutGroupRequest) Descriptor() ([]byte, []int) {
+ return file_iam_proto_rawDescGZIP(), []int{59}
+}
+
+func (x *PutGroupRequest) GetGroup() *Group {
+ if x != nil {
+ return x.Group
+ }
+ return nil
+}
+
+type PutGroupResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *PutGroupResponse) Reset() {
+ *x = PutGroupResponse{}
+ mi := &file_iam_proto_msgTypes[60]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *PutGroupResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PutGroupResponse) ProtoMessage() {}
+
+func (x *PutGroupResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_iam_proto_msgTypes[60]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use PutGroupResponse.ProtoReflect.Descriptor instead.
+func (*PutGroupResponse) Descriptor() ([]byte, []int) {
+ return file_iam_proto_rawDescGZIP(), []int{60}
+}
+
+type RemoveGroupRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ GroupName string `protobuf:"bytes,1,opt,name=group_name,json=groupName,proto3" json:"group_name,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RemoveGroupRequest) Reset() {
+ *x = RemoveGroupRequest{}
+ mi := &file_iam_proto_msgTypes[61]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RemoveGroupRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RemoveGroupRequest) ProtoMessage() {}
+
+func (x *RemoveGroupRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_iam_proto_msgTypes[61]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RemoveGroupRequest.ProtoReflect.Descriptor instead.
+func (*RemoveGroupRequest) Descriptor() ([]byte, []int) {
+ return file_iam_proto_rawDescGZIP(), []int{61}
+}
+
+func (x *RemoveGroupRequest) GetGroupName() string {
+ if x != nil {
+ return x.GroupName
+ }
+ return ""
+}
+
+type RemoveGroupResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *RemoveGroupResponse) Reset() {
+ *x = RemoveGroupResponse{}
+ mi := &file_iam_proto_msgTypes[62]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *RemoveGroupResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RemoveGroupResponse) ProtoMessage() {}
+
+func (x *RemoveGroupResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_iam_proto_msgTypes[62]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use RemoveGroupResponse.ProtoReflect.Descriptor instead.
+func (*RemoveGroupResponse) Descriptor() ([]byte, []int) {
+ return file_iam_proto_rawDescGZIP(), []int{62}
}
var File_iam_proto protoreflect.FileDescriptor
@@ -2764,14 +3000,20 @@ const file_iam_proto_rawDesc = "" +
"\busername\x18\x01 \x01(\tR\busername\x12\x1f\n" +
"\vpolicy_name\x18\x02 \x01(\tR\n" +
"policyName\"\x1a\n" +
- "\x18DeleteUserPolicyResponse\"\xe2\x01\n" +
+ "\x18DeleteUserPolicyResponse\"\x89\x02\n" +
"\x12S3ApiConfiguration\x120\n" +
"\n" +
"identities\x18\x01 \x03(\v2\x10.iam_pb.IdentityR\n" +
"identities\x12+\n" +
"\baccounts\x18\x02 \x03(\v2\x0f.iam_pb.AccountR\baccounts\x12A\n" +
"\x10service_accounts\x18\x03 \x03(\v2\x16.iam_pb.ServiceAccountR\x0fserviceAccounts\x12*\n" +
- "\bpolicies\x18\x04 \x03(\v2\x0e.iam_pb.PolicyR\bpolicies\"\x88\x02\n" +
+ "\bpolicies\x18\x04 \x03(\v2\x0e.iam_pb.PolicyR\bpolicies\x12%\n" +
+ "\x06groups\x18\x05 \x03(\v2\r.iam_pb.GroupR\x06groups\"t\n" +
+ "\x05Group\x12\x12\n" +
+ "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" +
+ "\amembers\x18\x02 \x03(\tR\amembers\x12!\n" +
+ "\fpolicy_names\x18\x03 \x03(\tR\vpolicyNames\x12\x1a\n" +
+ "\bdisabled\x18\x04 \x01(\bR\bdisabled\"\x88\x02\n" +
"\bIdentity\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x124\n" +
"\vcredentials\x18\x02 \x03(\v2\x12.iam_pb.CredentialR\vcredentials\x12\x18\n" +
@@ -2853,7 +3095,14 @@ const file_iam_proto_rawDesc = "" +
"\x13PutIdentityResponse\"3\n" +
"\x15RemoveIdentityRequest\x12\x1a\n" +
"\busername\x18\x01 \x01(\tR\busername\"\x18\n" +
- "\x16RemoveIdentityResponse2\x99\r\n" +
+ "\x16RemoveIdentityResponse\"6\n" +
+ "\x0fPutGroupRequest\x12#\n" +
+ "\x05group\x18\x01 \x01(\v2\r.iam_pb.GroupR\x05group\"\x12\n" +
+ "\x10PutGroupResponse\"3\n" +
+ "\x12RemoveGroupRequest\x12\x1d\n" +
+ "\n" +
+ "group_name\x18\x01 \x01(\tR\tgroupName\"\x15\n" +
+ "\x13RemoveGroupResponse2\x99\r\n" +
"\x1fSeaweedIdentityAccessManagement\x12U\n" +
"\x10GetConfiguration\x12\x1f.iam_pb.GetConfigurationRequest\x1a .iam_pb.GetConfigurationResponse\x12U\n" +
"\x10PutConfiguration\x12\x1f.iam_pb.PutConfigurationRequest\x1a .iam_pb.PutConfigurationResponse\x12C\n" +
@@ -2892,7 +3141,7 @@ func file_iam_proto_rawDescGZIP() []byte {
return file_iam_proto_rawDescData
}
-var file_iam_proto_msgTypes = make([]protoimpl.MessageInfo, 58)
+var file_iam_proto_msgTypes = make([]protoimpl.MessageInfo, 63)
var file_iam_proto_goTypes = []any{
(*GetConfigurationRequest)(nil), // 0: iam_pb.GetConfigurationRequest
(*GetConfigurationResponse)(nil), // 1: iam_pb.GetConfigurationResponse
@@ -2923,104 +3172,111 @@ var file_iam_proto_goTypes = []any{
(*DeleteUserPolicyRequest)(nil), // 26: iam_pb.DeleteUserPolicyRequest
(*DeleteUserPolicyResponse)(nil), // 27: iam_pb.DeleteUserPolicyResponse
(*S3ApiConfiguration)(nil), // 28: iam_pb.S3ApiConfiguration
- (*Identity)(nil), // 29: iam_pb.Identity
- (*Credential)(nil), // 30: iam_pb.Credential
- (*Account)(nil), // 31: iam_pb.Account
- (*ServiceAccount)(nil), // 32: iam_pb.ServiceAccount
- (*PutPolicyRequest)(nil), // 33: iam_pb.PutPolicyRequest
- (*PutPolicyResponse)(nil), // 34: iam_pb.PutPolicyResponse
- (*GetPolicyRequest)(nil), // 35: iam_pb.GetPolicyRequest
- (*GetPolicyResponse)(nil), // 36: iam_pb.GetPolicyResponse
- (*ListPoliciesRequest)(nil), // 37: iam_pb.ListPoliciesRequest
- (*ListPoliciesResponse)(nil), // 38: iam_pb.ListPoliciesResponse
- (*DeletePolicyRequest)(nil), // 39: iam_pb.DeletePolicyRequest
- (*DeletePolicyResponse)(nil), // 40: iam_pb.DeletePolicyResponse
- (*Policy)(nil), // 41: iam_pb.Policy
- (*CreateServiceAccountRequest)(nil), // 42: iam_pb.CreateServiceAccountRequest
- (*CreateServiceAccountResponse)(nil), // 43: iam_pb.CreateServiceAccountResponse
- (*UpdateServiceAccountRequest)(nil), // 44: iam_pb.UpdateServiceAccountRequest
- (*UpdateServiceAccountResponse)(nil), // 45: iam_pb.UpdateServiceAccountResponse
- (*DeleteServiceAccountRequest)(nil), // 46: iam_pb.DeleteServiceAccountRequest
- (*DeleteServiceAccountResponse)(nil), // 47: iam_pb.DeleteServiceAccountResponse
- (*GetServiceAccountRequest)(nil), // 48: iam_pb.GetServiceAccountRequest
- (*GetServiceAccountResponse)(nil), // 49: iam_pb.GetServiceAccountResponse
- (*ListServiceAccountsRequest)(nil), // 50: iam_pb.ListServiceAccountsRequest
- (*ListServiceAccountsResponse)(nil), // 51: iam_pb.ListServiceAccountsResponse
- (*GetServiceAccountByAccessKeyRequest)(nil), // 52: iam_pb.GetServiceAccountByAccessKeyRequest
- (*GetServiceAccountByAccessKeyResponse)(nil), // 53: iam_pb.GetServiceAccountByAccessKeyResponse
- (*PutIdentityRequest)(nil), // 54: iam_pb.PutIdentityRequest
- (*PutIdentityResponse)(nil), // 55: iam_pb.PutIdentityResponse
- (*RemoveIdentityRequest)(nil), // 56: iam_pb.RemoveIdentityRequest
- (*RemoveIdentityResponse)(nil), // 57: iam_pb.RemoveIdentityResponse
+ (*Group)(nil), // 29: iam_pb.Group
+ (*Identity)(nil), // 30: iam_pb.Identity
+ (*Credential)(nil), // 31: iam_pb.Credential
+ (*Account)(nil), // 32: iam_pb.Account
+ (*ServiceAccount)(nil), // 33: iam_pb.ServiceAccount
+ (*PutPolicyRequest)(nil), // 34: iam_pb.PutPolicyRequest
+ (*PutPolicyResponse)(nil), // 35: iam_pb.PutPolicyResponse
+ (*GetPolicyRequest)(nil), // 36: iam_pb.GetPolicyRequest
+ (*GetPolicyResponse)(nil), // 37: iam_pb.GetPolicyResponse
+ (*ListPoliciesRequest)(nil), // 38: iam_pb.ListPoliciesRequest
+ (*ListPoliciesResponse)(nil), // 39: iam_pb.ListPoliciesResponse
+ (*DeletePolicyRequest)(nil), // 40: iam_pb.DeletePolicyRequest
+ (*DeletePolicyResponse)(nil), // 41: iam_pb.DeletePolicyResponse
+ (*Policy)(nil), // 42: iam_pb.Policy
+ (*CreateServiceAccountRequest)(nil), // 43: iam_pb.CreateServiceAccountRequest
+ (*CreateServiceAccountResponse)(nil), // 44: iam_pb.CreateServiceAccountResponse
+ (*UpdateServiceAccountRequest)(nil), // 45: iam_pb.UpdateServiceAccountRequest
+ (*UpdateServiceAccountResponse)(nil), // 46: iam_pb.UpdateServiceAccountResponse
+ (*DeleteServiceAccountRequest)(nil), // 47: iam_pb.DeleteServiceAccountRequest
+ (*DeleteServiceAccountResponse)(nil), // 48: iam_pb.DeleteServiceAccountResponse
+ (*GetServiceAccountRequest)(nil), // 49: iam_pb.GetServiceAccountRequest
+ (*GetServiceAccountResponse)(nil), // 50: iam_pb.GetServiceAccountResponse
+ (*ListServiceAccountsRequest)(nil), // 51: iam_pb.ListServiceAccountsRequest
+ (*ListServiceAccountsResponse)(nil), // 52: iam_pb.ListServiceAccountsResponse
+ (*GetServiceAccountByAccessKeyRequest)(nil), // 53: iam_pb.GetServiceAccountByAccessKeyRequest
+ (*GetServiceAccountByAccessKeyResponse)(nil), // 54: iam_pb.GetServiceAccountByAccessKeyResponse
+ (*PutIdentityRequest)(nil), // 55: iam_pb.PutIdentityRequest
+ (*PutIdentityResponse)(nil), // 56: iam_pb.PutIdentityResponse
+ (*RemoveIdentityRequest)(nil), // 57: iam_pb.RemoveIdentityRequest
+ (*RemoveIdentityResponse)(nil), // 58: iam_pb.RemoveIdentityResponse
+ (*PutGroupRequest)(nil), // 59: iam_pb.PutGroupRequest
+ (*PutGroupResponse)(nil), // 60: iam_pb.PutGroupResponse
+ (*RemoveGroupRequest)(nil), // 61: iam_pb.RemoveGroupRequest
+ (*RemoveGroupResponse)(nil), // 62: iam_pb.RemoveGroupResponse
}
var file_iam_proto_depIdxs = []int32{
28, // 0: iam_pb.GetConfigurationResponse.configuration:type_name -> iam_pb.S3ApiConfiguration
28, // 1: iam_pb.PutConfigurationRequest.configuration:type_name -> iam_pb.S3ApiConfiguration
- 29, // 2: iam_pb.CreateUserRequest.identity:type_name -> iam_pb.Identity
- 29, // 3: iam_pb.GetUserResponse.identity:type_name -> iam_pb.Identity
- 29, // 4: iam_pb.UpdateUserRequest.identity:type_name -> iam_pb.Identity
- 30, // 5: iam_pb.CreateAccessKeyRequest.credential:type_name -> iam_pb.Credential
- 29, // 6: iam_pb.GetUserByAccessKeyResponse.identity:type_name -> iam_pb.Identity
- 30, // 7: iam_pb.ListAccessKeysResponse.access_keys:type_name -> iam_pb.Credential
- 29, // 8: iam_pb.S3ApiConfiguration.identities:type_name -> iam_pb.Identity
- 31, // 9: iam_pb.S3ApiConfiguration.accounts:type_name -> iam_pb.Account
- 32, // 10: iam_pb.S3ApiConfiguration.service_accounts:type_name -> iam_pb.ServiceAccount
- 41, // 11: iam_pb.S3ApiConfiguration.policies:type_name -> iam_pb.Policy
- 30, // 12: iam_pb.Identity.credentials:type_name -> iam_pb.Credential
- 31, // 13: iam_pb.Identity.account:type_name -> iam_pb.Account
- 30, // 14: iam_pb.ServiceAccount.credential:type_name -> iam_pb.Credential
- 41, // 15: iam_pb.ListPoliciesResponse.policies:type_name -> iam_pb.Policy
- 32, // 16: iam_pb.CreateServiceAccountRequest.service_account:type_name -> iam_pb.ServiceAccount
- 32, // 17: iam_pb.UpdateServiceAccountRequest.service_account:type_name -> iam_pb.ServiceAccount
- 32, // 18: iam_pb.GetServiceAccountResponse.service_account:type_name -> iam_pb.ServiceAccount
- 32, // 19: iam_pb.ListServiceAccountsResponse.service_accounts:type_name -> iam_pb.ServiceAccount
- 32, // 20: iam_pb.GetServiceAccountByAccessKeyResponse.service_account:type_name -> iam_pb.ServiceAccount
- 29, // 21: iam_pb.PutIdentityRequest.identity:type_name -> iam_pb.Identity
- 0, // 22: iam_pb.SeaweedIdentityAccessManagement.GetConfiguration:input_type -> iam_pb.GetConfigurationRequest
- 2, // 23: iam_pb.SeaweedIdentityAccessManagement.PutConfiguration:input_type -> iam_pb.PutConfigurationRequest
- 4, // 24: iam_pb.SeaweedIdentityAccessManagement.CreateUser:input_type -> iam_pb.CreateUserRequest
- 6, // 25: iam_pb.SeaweedIdentityAccessManagement.GetUser:input_type -> iam_pb.GetUserRequest
- 8, // 26: iam_pb.SeaweedIdentityAccessManagement.UpdateUser:input_type -> iam_pb.UpdateUserRequest
- 10, // 27: iam_pb.SeaweedIdentityAccessManagement.DeleteUser:input_type -> iam_pb.DeleteUserRequest
- 12, // 28: iam_pb.SeaweedIdentityAccessManagement.ListUsers:input_type -> iam_pb.ListUsersRequest
- 14, // 29: iam_pb.SeaweedIdentityAccessManagement.CreateAccessKey:input_type -> iam_pb.CreateAccessKeyRequest
- 16, // 30: iam_pb.SeaweedIdentityAccessManagement.DeleteAccessKey:input_type -> iam_pb.DeleteAccessKeyRequest
- 18, // 31: iam_pb.SeaweedIdentityAccessManagement.GetUserByAccessKey:input_type -> iam_pb.GetUserByAccessKeyRequest
- 33, // 32: iam_pb.SeaweedIdentityAccessManagement.PutPolicy:input_type -> iam_pb.PutPolicyRequest
- 35, // 33: iam_pb.SeaweedIdentityAccessManagement.GetPolicy:input_type -> iam_pb.GetPolicyRequest
- 37, // 34: iam_pb.SeaweedIdentityAccessManagement.ListPolicies:input_type -> iam_pb.ListPoliciesRequest
- 39, // 35: iam_pb.SeaweedIdentityAccessManagement.DeletePolicy:input_type -> iam_pb.DeletePolicyRequest
- 42, // 36: iam_pb.SeaweedIdentityAccessManagement.CreateServiceAccount:input_type -> iam_pb.CreateServiceAccountRequest
- 44, // 37: iam_pb.SeaweedIdentityAccessManagement.UpdateServiceAccount:input_type -> iam_pb.UpdateServiceAccountRequest
- 46, // 38: iam_pb.SeaweedIdentityAccessManagement.DeleteServiceAccount:input_type -> iam_pb.DeleteServiceAccountRequest
- 48, // 39: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccount:input_type -> iam_pb.GetServiceAccountRequest
- 50, // 40: iam_pb.SeaweedIdentityAccessManagement.ListServiceAccounts:input_type -> iam_pb.ListServiceAccountsRequest
- 52, // 41: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccountByAccessKey:input_type -> iam_pb.GetServiceAccountByAccessKeyRequest
- 1, // 42: iam_pb.SeaweedIdentityAccessManagement.GetConfiguration:output_type -> iam_pb.GetConfigurationResponse
- 3, // 43: iam_pb.SeaweedIdentityAccessManagement.PutConfiguration:output_type -> iam_pb.PutConfigurationResponse
- 5, // 44: iam_pb.SeaweedIdentityAccessManagement.CreateUser:output_type -> iam_pb.CreateUserResponse
- 7, // 45: iam_pb.SeaweedIdentityAccessManagement.GetUser:output_type -> iam_pb.GetUserResponse
- 9, // 46: iam_pb.SeaweedIdentityAccessManagement.UpdateUser:output_type -> iam_pb.UpdateUserResponse
- 11, // 47: iam_pb.SeaweedIdentityAccessManagement.DeleteUser:output_type -> iam_pb.DeleteUserResponse
- 13, // 48: iam_pb.SeaweedIdentityAccessManagement.ListUsers:output_type -> iam_pb.ListUsersResponse
- 15, // 49: iam_pb.SeaweedIdentityAccessManagement.CreateAccessKey:output_type -> iam_pb.CreateAccessKeyResponse
- 17, // 50: iam_pb.SeaweedIdentityAccessManagement.DeleteAccessKey:output_type -> iam_pb.DeleteAccessKeyResponse
- 19, // 51: iam_pb.SeaweedIdentityAccessManagement.GetUserByAccessKey:output_type -> iam_pb.GetUserByAccessKeyResponse
- 34, // 52: iam_pb.SeaweedIdentityAccessManagement.PutPolicy:output_type -> iam_pb.PutPolicyResponse
- 36, // 53: iam_pb.SeaweedIdentityAccessManagement.GetPolicy:output_type -> iam_pb.GetPolicyResponse
- 38, // 54: iam_pb.SeaweedIdentityAccessManagement.ListPolicies:output_type -> iam_pb.ListPoliciesResponse
- 40, // 55: iam_pb.SeaweedIdentityAccessManagement.DeletePolicy:output_type -> iam_pb.DeletePolicyResponse
- 43, // 56: iam_pb.SeaweedIdentityAccessManagement.CreateServiceAccount:output_type -> iam_pb.CreateServiceAccountResponse
- 45, // 57: iam_pb.SeaweedIdentityAccessManagement.UpdateServiceAccount:output_type -> iam_pb.UpdateServiceAccountResponse
- 47, // 58: iam_pb.SeaweedIdentityAccessManagement.DeleteServiceAccount:output_type -> iam_pb.DeleteServiceAccountResponse
- 49, // 59: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccount:output_type -> iam_pb.GetServiceAccountResponse
- 51, // 60: iam_pb.SeaweedIdentityAccessManagement.ListServiceAccounts:output_type -> iam_pb.ListServiceAccountsResponse
- 53, // 61: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccountByAccessKey:output_type -> iam_pb.GetServiceAccountByAccessKeyResponse
- 42, // [42:62] is the sub-list for method output_type
- 22, // [22:42] is the sub-list for method input_type
- 22, // [22:22] is the sub-list for extension type_name
- 22, // [22:22] is the sub-list for extension extendee
- 0, // [0:22] is the sub-list for field type_name
+ 30, // 2: iam_pb.CreateUserRequest.identity:type_name -> iam_pb.Identity
+ 30, // 3: iam_pb.GetUserResponse.identity:type_name -> iam_pb.Identity
+ 30, // 4: iam_pb.UpdateUserRequest.identity:type_name -> iam_pb.Identity
+ 31, // 5: iam_pb.CreateAccessKeyRequest.credential:type_name -> iam_pb.Credential
+ 30, // 6: iam_pb.GetUserByAccessKeyResponse.identity:type_name -> iam_pb.Identity
+ 31, // 7: iam_pb.ListAccessKeysResponse.access_keys:type_name -> iam_pb.Credential
+ 30, // 8: iam_pb.S3ApiConfiguration.identities:type_name -> iam_pb.Identity
+ 32, // 9: iam_pb.S3ApiConfiguration.accounts:type_name -> iam_pb.Account
+ 33, // 10: iam_pb.S3ApiConfiguration.service_accounts:type_name -> iam_pb.ServiceAccount
+ 42, // 11: iam_pb.S3ApiConfiguration.policies:type_name -> iam_pb.Policy
+ 29, // 12: iam_pb.S3ApiConfiguration.groups:type_name -> iam_pb.Group
+ 31, // 13: iam_pb.Identity.credentials:type_name -> iam_pb.Credential
+ 32, // 14: iam_pb.Identity.account:type_name -> iam_pb.Account
+ 31, // 15: iam_pb.ServiceAccount.credential:type_name -> iam_pb.Credential
+ 42, // 16: iam_pb.ListPoliciesResponse.policies:type_name -> iam_pb.Policy
+ 33, // 17: iam_pb.CreateServiceAccountRequest.service_account:type_name -> iam_pb.ServiceAccount
+ 33, // 18: iam_pb.UpdateServiceAccountRequest.service_account:type_name -> iam_pb.ServiceAccount
+ 33, // 19: iam_pb.GetServiceAccountResponse.service_account:type_name -> iam_pb.ServiceAccount
+ 33, // 20: iam_pb.ListServiceAccountsResponse.service_accounts:type_name -> iam_pb.ServiceAccount
+ 33, // 21: iam_pb.GetServiceAccountByAccessKeyResponse.service_account:type_name -> iam_pb.ServiceAccount
+ 30, // 22: iam_pb.PutIdentityRequest.identity:type_name -> iam_pb.Identity
+ 29, // 23: iam_pb.PutGroupRequest.group:type_name -> iam_pb.Group
+ 0, // 24: iam_pb.SeaweedIdentityAccessManagement.GetConfiguration:input_type -> iam_pb.GetConfigurationRequest
+ 2, // 25: iam_pb.SeaweedIdentityAccessManagement.PutConfiguration:input_type -> iam_pb.PutConfigurationRequest
+ 4, // 26: iam_pb.SeaweedIdentityAccessManagement.CreateUser:input_type -> iam_pb.CreateUserRequest
+ 6, // 27: iam_pb.SeaweedIdentityAccessManagement.GetUser:input_type -> iam_pb.GetUserRequest
+ 8, // 28: iam_pb.SeaweedIdentityAccessManagement.UpdateUser:input_type -> iam_pb.UpdateUserRequest
+ 10, // 29: iam_pb.SeaweedIdentityAccessManagement.DeleteUser:input_type -> iam_pb.DeleteUserRequest
+ 12, // 30: iam_pb.SeaweedIdentityAccessManagement.ListUsers:input_type -> iam_pb.ListUsersRequest
+ 14, // 31: iam_pb.SeaweedIdentityAccessManagement.CreateAccessKey:input_type -> iam_pb.CreateAccessKeyRequest
+ 16, // 32: iam_pb.SeaweedIdentityAccessManagement.DeleteAccessKey:input_type -> iam_pb.DeleteAccessKeyRequest
+ 18, // 33: iam_pb.SeaweedIdentityAccessManagement.GetUserByAccessKey:input_type -> iam_pb.GetUserByAccessKeyRequest
+ 34, // 34: iam_pb.SeaweedIdentityAccessManagement.PutPolicy:input_type -> iam_pb.PutPolicyRequest
+ 36, // 35: iam_pb.SeaweedIdentityAccessManagement.GetPolicy:input_type -> iam_pb.GetPolicyRequest
+ 38, // 36: iam_pb.SeaweedIdentityAccessManagement.ListPolicies:input_type -> iam_pb.ListPoliciesRequest
+ 40, // 37: iam_pb.SeaweedIdentityAccessManagement.DeletePolicy:input_type -> iam_pb.DeletePolicyRequest
+ 43, // 38: iam_pb.SeaweedIdentityAccessManagement.CreateServiceAccount:input_type -> iam_pb.CreateServiceAccountRequest
+ 45, // 39: iam_pb.SeaweedIdentityAccessManagement.UpdateServiceAccount:input_type -> iam_pb.UpdateServiceAccountRequest
+ 47, // 40: iam_pb.SeaweedIdentityAccessManagement.DeleteServiceAccount:input_type -> iam_pb.DeleteServiceAccountRequest
+ 49, // 41: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccount:input_type -> iam_pb.GetServiceAccountRequest
+ 51, // 42: iam_pb.SeaweedIdentityAccessManagement.ListServiceAccounts:input_type -> iam_pb.ListServiceAccountsRequest
+ 53, // 43: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccountByAccessKey:input_type -> iam_pb.GetServiceAccountByAccessKeyRequest
+ 1, // 44: iam_pb.SeaweedIdentityAccessManagement.GetConfiguration:output_type -> iam_pb.GetConfigurationResponse
+ 3, // 45: iam_pb.SeaweedIdentityAccessManagement.PutConfiguration:output_type -> iam_pb.PutConfigurationResponse
+ 5, // 46: iam_pb.SeaweedIdentityAccessManagement.CreateUser:output_type -> iam_pb.CreateUserResponse
+ 7, // 47: iam_pb.SeaweedIdentityAccessManagement.GetUser:output_type -> iam_pb.GetUserResponse
+ 9, // 48: iam_pb.SeaweedIdentityAccessManagement.UpdateUser:output_type -> iam_pb.UpdateUserResponse
+ 11, // 49: iam_pb.SeaweedIdentityAccessManagement.DeleteUser:output_type -> iam_pb.DeleteUserResponse
+ 13, // 50: iam_pb.SeaweedIdentityAccessManagement.ListUsers:output_type -> iam_pb.ListUsersResponse
+ 15, // 51: iam_pb.SeaweedIdentityAccessManagement.CreateAccessKey:output_type -> iam_pb.CreateAccessKeyResponse
+ 17, // 52: iam_pb.SeaweedIdentityAccessManagement.DeleteAccessKey:output_type -> iam_pb.DeleteAccessKeyResponse
+ 19, // 53: iam_pb.SeaweedIdentityAccessManagement.GetUserByAccessKey:output_type -> iam_pb.GetUserByAccessKeyResponse
+ 35, // 54: iam_pb.SeaweedIdentityAccessManagement.PutPolicy:output_type -> iam_pb.PutPolicyResponse
+ 37, // 55: iam_pb.SeaweedIdentityAccessManagement.GetPolicy:output_type -> iam_pb.GetPolicyResponse
+ 39, // 56: iam_pb.SeaweedIdentityAccessManagement.ListPolicies:output_type -> iam_pb.ListPoliciesResponse
+ 41, // 57: iam_pb.SeaweedIdentityAccessManagement.DeletePolicy:output_type -> iam_pb.DeletePolicyResponse
+ 44, // 58: iam_pb.SeaweedIdentityAccessManagement.CreateServiceAccount:output_type -> iam_pb.CreateServiceAccountResponse
+ 46, // 59: iam_pb.SeaweedIdentityAccessManagement.UpdateServiceAccount:output_type -> iam_pb.UpdateServiceAccountResponse
+ 48, // 60: iam_pb.SeaweedIdentityAccessManagement.DeleteServiceAccount:output_type -> iam_pb.DeleteServiceAccountResponse
+ 50, // 61: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccount:output_type -> iam_pb.GetServiceAccountResponse
+ 52, // 62: iam_pb.SeaweedIdentityAccessManagement.ListServiceAccounts:output_type -> iam_pb.ListServiceAccountsResponse
+ 54, // 63: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccountByAccessKey:output_type -> iam_pb.GetServiceAccountByAccessKeyResponse
+ 44, // [44:64] is the sub-list for method output_type
+ 24, // [24:44] is the sub-list for method input_type
+ 24, // [24:24] is the sub-list for extension type_name
+ 24, // [24:24] is the sub-list for extension extendee
+ 0, // [0:24] is the sub-list for field type_name
}
func init() { file_iam_proto_init() }
@@ -3034,7 +3290,7 @@ func file_iam_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_iam_proto_rawDesc), len(file_iam_proto_rawDesc)),
NumEnums: 0,
- NumMessages: 58,
+ NumMessages: 63,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/weed/pb/s3.proto b/weed/pb/s3.proto
index 8deb87ada..73a09d520 100644
--- a/weed/pb/s3.proto
+++ b/weed/pb/s3.proto
@@ -18,6 +18,8 @@ service SeaweedS3IamCache {
rpc GetPolicy (iam_pb.GetPolicyRequest) returns (iam_pb.GetPolicyResponse);
rpc ListPolicies (iam_pb.ListPoliciesRequest) returns (iam_pb.ListPoliciesResponse);
rpc DeletePolicy (iam_pb.DeletePolicyRequest) returns (iam_pb.DeletePolicyResponse);
+ rpc PutGroup (iam_pb.PutGroupRequest) returns (iam_pb.PutGroupResponse);
+ rpc RemoveGroup (iam_pb.RemoveGroupRequest) returns (iam_pb.RemoveGroupResponse);
}
//////////////////////////////////////////////////
diff --git a/weed/pb/s3_pb/s3.pb.go b/weed/pb/s3_pb/s3.pb.go
index 308eaf95e..35b377de5 100644
--- a/weed/pb/s3_pb/s3.pb.go
+++ b/weed/pb/s3_pb/s3.pb.go
@@ -414,14 +414,16 @@ const file_s3_proto_rawDesc = "" +
"\rsse_algorithm\x18\x01 \x01(\tR\fsseAlgorithm\x12\x1c\n" +
"\n" +
"kms_key_id\x18\x02 \x01(\tR\bkmsKeyId\x12,\n" +
- "\x12bucket_key_enabled\x18\x03 \x01(\bR\x10bucketKeyEnabled2\xc6\x03\n" +
+ "\x12bucket_key_enabled\x18\x03 \x01(\bR\x10bucketKeyEnabled2\xcd\x04\n" +
"\x11SeaweedS3IamCache\x12F\n" +
"\vPutIdentity\x12\x1a.iam_pb.PutIdentityRequest\x1a\x1b.iam_pb.PutIdentityResponse\x12O\n" +
"\x0eRemoveIdentity\x12\x1d.iam_pb.RemoveIdentityRequest\x1a\x1e.iam_pb.RemoveIdentityResponse\x12@\n" +
"\tPutPolicy\x12\x18.iam_pb.PutPolicyRequest\x1a\x19.iam_pb.PutPolicyResponse\x12@\n" +
"\tGetPolicy\x12\x18.iam_pb.GetPolicyRequest\x1a\x19.iam_pb.GetPolicyResponse\x12I\n" +
"\fListPolicies\x12\x1b.iam_pb.ListPoliciesRequest\x1a\x1c.iam_pb.ListPoliciesResponse\x12I\n" +
- "\fDeletePolicy\x12\x1b.iam_pb.DeletePolicyRequest\x1a\x1c.iam_pb.DeletePolicyResponseBI\n" +
+ "\fDeletePolicy\x12\x1b.iam_pb.DeletePolicyRequest\x1a\x1c.iam_pb.DeletePolicyResponse\x12=\n" +
+ "\bPutGroup\x12\x17.iam_pb.PutGroupRequest\x1a\x18.iam_pb.PutGroupResponse\x12F\n" +
+ "\vRemoveGroup\x12\x1a.iam_pb.RemoveGroupRequest\x1a\x1b.iam_pb.RemoveGroupResponseBI\n" +
"\x10seaweedfs.clientB\aS3ProtoZ,github.com/seaweedfs/seaweedfs/weed/pb/s3_pbb\x06proto3"
var (
@@ -453,12 +455,16 @@ var file_s3_proto_goTypes = []any{
(*iam_pb.GetPolicyRequest)(nil), // 12: iam_pb.GetPolicyRequest
(*iam_pb.ListPoliciesRequest)(nil), // 13: iam_pb.ListPoliciesRequest
(*iam_pb.DeletePolicyRequest)(nil), // 14: iam_pb.DeletePolicyRequest
- (*iam_pb.PutIdentityResponse)(nil), // 15: iam_pb.PutIdentityResponse
- (*iam_pb.RemoveIdentityResponse)(nil), // 16: iam_pb.RemoveIdentityResponse
- (*iam_pb.PutPolicyResponse)(nil), // 17: iam_pb.PutPolicyResponse
- (*iam_pb.GetPolicyResponse)(nil), // 18: iam_pb.GetPolicyResponse
- (*iam_pb.ListPoliciesResponse)(nil), // 19: iam_pb.ListPoliciesResponse
- (*iam_pb.DeletePolicyResponse)(nil), // 20: iam_pb.DeletePolicyResponse
+ (*iam_pb.PutGroupRequest)(nil), // 15: iam_pb.PutGroupRequest
+ (*iam_pb.RemoveGroupRequest)(nil), // 16: iam_pb.RemoveGroupRequest
+ (*iam_pb.PutIdentityResponse)(nil), // 17: iam_pb.PutIdentityResponse
+ (*iam_pb.RemoveIdentityResponse)(nil), // 18: iam_pb.RemoveIdentityResponse
+ (*iam_pb.PutPolicyResponse)(nil), // 19: iam_pb.PutPolicyResponse
+ (*iam_pb.GetPolicyResponse)(nil), // 20: iam_pb.GetPolicyResponse
+ (*iam_pb.ListPoliciesResponse)(nil), // 21: iam_pb.ListPoliciesResponse
+ (*iam_pb.DeletePolicyResponse)(nil), // 22: iam_pb.DeletePolicyResponse
+ (*iam_pb.PutGroupResponse)(nil), // 23: iam_pb.PutGroupResponse
+ (*iam_pb.RemoveGroupResponse)(nil), // 24: iam_pb.RemoveGroupResponse
}
var file_s3_proto_depIdxs = []int32{
1, // 0: messaging_pb.S3CircuitBreakerConfig.global:type_name -> messaging_pb.S3CircuitBreakerOptions
@@ -475,14 +481,18 @@ var file_s3_proto_depIdxs = []int32{
12, // 11: messaging_pb.SeaweedS3IamCache.GetPolicy:input_type -> iam_pb.GetPolicyRequest
13, // 12: messaging_pb.SeaweedS3IamCache.ListPolicies:input_type -> iam_pb.ListPoliciesRequest
14, // 13: messaging_pb.SeaweedS3IamCache.DeletePolicy:input_type -> iam_pb.DeletePolicyRequest
- 15, // 14: messaging_pb.SeaweedS3IamCache.PutIdentity:output_type -> iam_pb.PutIdentityResponse
- 16, // 15: messaging_pb.SeaweedS3IamCache.RemoveIdentity:output_type -> iam_pb.RemoveIdentityResponse
- 17, // 16: messaging_pb.SeaweedS3IamCache.PutPolicy:output_type -> iam_pb.PutPolicyResponse
- 18, // 17: messaging_pb.SeaweedS3IamCache.GetPolicy:output_type -> iam_pb.GetPolicyResponse
- 19, // 18: messaging_pb.SeaweedS3IamCache.ListPolicies:output_type -> iam_pb.ListPoliciesResponse
- 20, // 19: messaging_pb.SeaweedS3IamCache.DeletePolicy:output_type -> iam_pb.DeletePolicyResponse
- 14, // [14:20] is the sub-list for method output_type
- 8, // [8:14] is the sub-list for method input_type
+ 15, // 14: messaging_pb.SeaweedS3IamCache.PutGroup:input_type -> iam_pb.PutGroupRequest
+ 16, // 15: messaging_pb.SeaweedS3IamCache.RemoveGroup:input_type -> iam_pb.RemoveGroupRequest
+ 17, // 16: messaging_pb.SeaweedS3IamCache.PutIdentity:output_type -> iam_pb.PutIdentityResponse
+ 18, // 17: messaging_pb.SeaweedS3IamCache.RemoveIdentity:output_type -> iam_pb.RemoveIdentityResponse
+ 19, // 18: messaging_pb.SeaweedS3IamCache.PutPolicy:output_type -> iam_pb.PutPolicyResponse
+ 20, // 19: messaging_pb.SeaweedS3IamCache.GetPolicy:output_type -> iam_pb.GetPolicyResponse
+ 21, // 20: messaging_pb.SeaweedS3IamCache.ListPolicies:output_type -> iam_pb.ListPoliciesResponse
+ 22, // 21: messaging_pb.SeaweedS3IamCache.DeletePolicy:output_type -> iam_pb.DeletePolicyResponse
+ 23, // 22: messaging_pb.SeaweedS3IamCache.PutGroup:output_type -> iam_pb.PutGroupResponse
+ 24, // 23: messaging_pb.SeaweedS3IamCache.RemoveGroup:output_type -> iam_pb.RemoveGroupResponse
+ 16, // [16:24] is the sub-list for method output_type
+ 8, // [8:16] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
diff --git a/weed/pb/s3_pb/s3_grpc.pb.go b/weed/pb/s3_pb/s3_grpc.pb.go
index 7e6b46b92..65b61b65a 100644
--- a/weed/pb/s3_pb/s3_grpc.pb.go
+++ b/weed/pb/s3_pb/s3_grpc.pb.go
@@ -26,6 +26,8 @@ const (
SeaweedS3IamCache_GetPolicy_FullMethodName = "/messaging_pb.SeaweedS3IamCache/GetPolicy"
SeaweedS3IamCache_ListPolicies_FullMethodName = "/messaging_pb.SeaweedS3IamCache/ListPolicies"
SeaweedS3IamCache_DeletePolicy_FullMethodName = "/messaging_pb.SeaweedS3IamCache/DeletePolicy"
+ SeaweedS3IamCache_PutGroup_FullMethodName = "/messaging_pb.SeaweedS3IamCache/PutGroup"
+ SeaweedS3IamCache_RemoveGroup_FullMethodName = "/messaging_pb.SeaweedS3IamCache/RemoveGroup"
)
// SeaweedS3IamCacheClient is the client API for SeaweedS3IamCache service.
@@ -40,6 +42,8 @@ type SeaweedS3IamCacheClient interface {
GetPolicy(ctx context.Context, in *iam_pb.GetPolicyRequest, opts ...grpc.CallOption) (*iam_pb.GetPolicyResponse, error)
ListPolicies(ctx context.Context, in *iam_pb.ListPoliciesRequest, opts ...grpc.CallOption) (*iam_pb.ListPoliciesResponse, error)
DeletePolicy(ctx context.Context, in *iam_pb.DeletePolicyRequest, opts ...grpc.CallOption) (*iam_pb.DeletePolicyResponse, error)
+ PutGroup(ctx context.Context, in *iam_pb.PutGroupRequest, opts ...grpc.CallOption) (*iam_pb.PutGroupResponse, error)
+ RemoveGroup(ctx context.Context, in *iam_pb.RemoveGroupRequest, opts ...grpc.CallOption) (*iam_pb.RemoveGroupResponse, error)
}
type seaweedS3IamCacheClient struct {
@@ -110,6 +114,26 @@ func (c *seaweedS3IamCacheClient) DeletePolicy(ctx context.Context, in *iam_pb.D
return out, nil
}
+func (c *seaweedS3IamCacheClient) PutGroup(ctx context.Context, in *iam_pb.PutGroupRequest, opts ...grpc.CallOption) (*iam_pb.PutGroupResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(iam_pb.PutGroupResponse)
+ err := c.cc.Invoke(ctx, SeaweedS3IamCache_PutGroup_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func (c *seaweedS3IamCacheClient) RemoveGroup(ctx context.Context, in *iam_pb.RemoveGroupRequest, opts ...grpc.CallOption) (*iam_pb.RemoveGroupResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(iam_pb.RemoveGroupResponse)
+ err := c.cc.Invoke(ctx, SeaweedS3IamCache_RemoveGroup_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
// SeaweedS3IamCacheServer is the server API for SeaweedS3IamCache service.
// All implementations must embed UnimplementedSeaweedS3IamCacheServer
// for forward compatibility.
@@ -122,6 +146,8 @@ type SeaweedS3IamCacheServer interface {
GetPolicy(context.Context, *iam_pb.GetPolicyRequest) (*iam_pb.GetPolicyResponse, error)
ListPolicies(context.Context, *iam_pb.ListPoliciesRequest) (*iam_pb.ListPoliciesResponse, error)
DeletePolicy(context.Context, *iam_pb.DeletePolicyRequest) (*iam_pb.DeletePolicyResponse, error)
+ PutGroup(context.Context, *iam_pb.PutGroupRequest) (*iam_pb.PutGroupResponse, error)
+ RemoveGroup(context.Context, *iam_pb.RemoveGroupRequest) (*iam_pb.RemoveGroupResponse, error)
mustEmbedUnimplementedSeaweedS3IamCacheServer()
}
@@ -150,6 +176,12 @@ func (UnimplementedSeaweedS3IamCacheServer) ListPolicies(context.Context, *iam_p
func (UnimplementedSeaweedS3IamCacheServer) DeletePolicy(context.Context, *iam_pb.DeletePolicyRequest) (*iam_pb.DeletePolicyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeletePolicy not implemented")
}
+func (UnimplementedSeaweedS3IamCacheServer) PutGroup(context.Context, *iam_pb.PutGroupRequest) (*iam_pb.PutGroupResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method PutGroup not implemented")
+}
+func (UnimplementedSeaweedS3IamCacheServer) RemoveGroup(context.Context, *iam_pb.RemoveGroupRequest) (*iam_pb.RemoveGroupResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method RemoveGroup not implemented")
+}
func (UnimplementedSeaweedS3IamCacheServer) mustEmbedUnimplementedSeaweedS3IamCacheServer() {}
func (UnimplementedSeaweedS3IamCacheServer) testEmbeddedByValue() {}
@@ -279,6 +311,42 @@ func _SeaweedS3IamCache_DeletePolicy_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
+func _SeaweedS3IamCache_PutGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(iam_pb.PutGroupRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(SeaweedS3IamCacheServer).PutGroup(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: SeaweedS3IamCache_PutGroup_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(SeaweedS3IamCacheServer).PutGroup(ctx, req.(*iam_pb.PutGroupRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+func _SeaweedS3IamCache_RemoveGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(iam_pb.RemoveGroupRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(SeaweedS3IamCacheServer).RemoveGroup(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: SeaweedS3IamCache_RemoveGroup_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(SeaweedS3IamCacheServer).RemoveGroup(ctx, req.(*iam_pb.RemoveGroupRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
// SeaweedS3IamCache_ServiceDesc is the grpc.ServiceDesc for SeaweedS3IamCache service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -310,6 +378,14 @@ var SeaweedS3IamCache_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeletePolicy",
Handler: _SeaweedS3IamCache_DeletePolicy_Handler,
},
+ {
+ MethodName: "PutGroup",
+ Handler: _SeaweedS3IamCache_PutGroup_Handler,
+ },
+ {
+ MethodName: "RemoveGroup",
+ Handler: _SeaweedS3IamCache_RemoveGroup_Handler,
+ },
},
Streams: []grpc.StreamDesc{},
Metadata: "s3.proto",
diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go
index ae7b48be3..04309add5 100644
--- a/weed/s3api/auth_credentials.go
+++ b/weed/s3api/auth_credentials.go
@@ -49,6 +49,8 @@ type IdentityAccessManagement struct {
accessKeyIdent map[string]*Identity
nameToIdentity map[string]*Identity // O(1) lookup by identity name
policies map[string]*iam_pb.Policy
+ groups map[string]*iam_pb.Group // group name -> group
+ userGroups map[string][]string // user name -> group names (reverse index)
accounts map[string]*Account
emailAccount map[string]*Account
hashes map[string]*sync.Pool
@@ -563,6 +565,16 @@ func (iam *IdentityAccessManagement) ReplaceS3ApiConfiguration(config *iam_pb.S3
for _, policy := range config.Policies {
policies[policy.Name] = policy
}
+ groups := make(map[string]*iam_pb.Group)
+ userGroupsMap := make(map[string][]string)
+ for _, g := range config.Groups {
+ groups[g.Name] = g
+ if !g.Disabled {
+ for _, member := range g.Members {
+ userGroupsMap[member] = append(userGroupsMap[member], g.Name)
+ }
+ }
+ }
for _, ident := range config.Identities {
glog.V(3).Infof("loading identity %s (disabled=%v)", ident.Name, ident.Disabled)
t := &Identity{
@@ -663,6 +675,8 @@ func (iam *IdentityAccessManagement) ReplaceS3ApiConfiguration(config *iam_pb.S3
iam.nameToIdentity = nameToIdentity
iam.accessKeyIdent = accessKeyIdent
iam.policies = policies
+ iam.groups = groups
+ iam.userGroups = userGroupsMap
iam.rebuildIAMPolicyEngineLocked()
// Re-add environment-based identities that were preserved
@@ -699,7 +713,7 @@ func (iam *IdentityAccessManagement) ReplaceS3ApiConfiguration(config *iam_pb.S3
iam.loadEnvironmentVariableCredentials()
// Log configuration summary - always log to help debugging
- glog.Infof("Loaded %d identities, %d accounts, %d access keys. Auth enabled: %v",
+ glog.V(1).Infof("Loaded %d identities, %d accounts, %d access keys. Auth enabled: %v",
len(iam.identities), len(iam.accounts), len(iam.accessKeyIdent), iam.isAuthEnabled)
if glog.V(2) {
@@ -920,6 +934,23 @@ func (iam *IdentityAccessManagement) MergeS3ApiConfiguration(config *iam_pb.S3Ap
iam.nameToIdentity = nameToIdentity
iam.accessKeyIdent = accessKeyIdent
iam.policies = policies
+
+ // Process groups: only replace if config.Groups is non-nil (full config reload).
+ // Partial updates (e.g., UpsertIdentity) pass nil Groups and should preserve existing state.
+ if config.Groups != nil {
+ mergedGroups := make(map[string]*iam_pb.Group)
+ mergedUserGroups := make(map[string][]string)
+ for _, g := range config.Groups {
+ mergedGroups[g.Name] = g
+ if !g.Disabled {
+ for _, member := range g.Members {
+ mergedUserGroups[member] = append(mergedUserGroups[member], g.Name)
+ }
+ }
+ }
+ iam.groups = mergedGroups
+ iam.userGroups = mergedUserGroups
+ }
iam.rebuildIAMPolicyEngineLocked()
// Update authentication state based on whether identities exist
// Once enabled, keep it enabled (one-way toggle)
@@ -1837,14 +1868,32 @@ func determineIAMAuthPath(sessionToken, principal, principalArn string) iamAuthP
// Returns true if any matching statement explicitly allows the action.
// Uses the cached iamPolicyEngine to avoid re-parsing policy JSON on every request.
func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identity *Identity, action Action, bucket, object string) bool {
- if identity == nil || len(identity.PolicyNames) == 0 {
+ if identity == nil {
return false
}
iam.m.RLock()
engine := iam.iamPolicyEngine
+ groupNames := iam.userGroups[identity.Name]
+ // Snapshot group policy names to avoid holding the lock during evaluation.
+ // We copy the needed data since PutGroup/RemoveGroup mutate iam.groups in-place.
+ var groupPolicies [][]string
+ for _, gName := range groupNames {
+ g, ok := iam.groups[gName]
+ if !ok || g.Disabled {
+ continue
+ }
+ policyNames := make([]string, len(g.PolicyNames))
+ copy(policyNames, g.PolicyNames)
+ groupPolicies = append(groupPolicies, policyNames)
+ }
iam.m.RUnlock()
+ // Collect all policy names: user policies + group policies
+ if len(identity.PolicyNames) == 0 && len(groupPolicies) == 0 {
+ return false
+ }
+
if engine == nil {
return false
}
@@ -1858,15 +1907,17 @@ func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identi
conditions[k] = v
}
- for _, policyName := range identity.PolicyNames {
- result := engine.EvaluatePolicy(policyName, &policy_engine.PolicyEvaluationArgs{
- Action: s3Action,
- Resource: resource,
- Principal: principal,
- Conditions: conditions,
- Claims: identity.Claims,
- })
+ evalArgs := &policy_engine.PolicyEvaluationArgs{
+ Action: s3Action,
+ Resource: resource,
+ Principal: principal,
+ Conditions: conditions,
+ Claims: identity.Claims,
+ }
+ // Evaluate user's own policies
+ for _, policyName := range identity.PolicyNames {
+ result := engine.EvaluatePolicy(policyName, evalArgs)
if result == policy_engine.PolicyResultDeny {
return false
}
@@ -1875,6 +1926,19 @@ func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identi
}
}
+ // Evaluate policies from user's groups
+ for _, policyNames := range groupPolicies {
+ for _, policyName := range policyNames {
+ result := engine.EvaluatePolicy(policyName, evalArgs)
+ if result == policy_engine.PolicyResultDeny {
+ return false
+ }
+ if result == policy_engine.PolicyResultAllow {
+ explicitAllow = true
+ }
+ }
+ }
+
return explicitAllow
}
@@ -1894,7 +1958,17 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide
hasSessionToken := r.Header.Get("X-SeaweedFS-Session-Token") != "" ||
r.Header.Get("X-Amz-Security-Token") != "" ||
r.URL.Query().Get("X-Amz-Security-Token") != ""
- hasAttachedPolicies := len(identity.PolicyNames) > 0
+ iam.m.RLock()
+ userGroupNames := iam.userGroups[identity.Name]
+ groupsHavePolicies := false
+ for _, gn := range userGroupNames {
+ if g, ok := iam.groups[gn]; ok && !g.Disabled && len(g.PolicyNames) > 0 {
+ groupsHavePolicies = true
+ break
+ }
+ }
+ iam.m.RUnlock()
+ hasAttachedPolicies := len(identity.PolicyNames) > 0 || groupsHavePolicies
if (len(identity.Actions) == 0 || hasSessionToken || hasAttachedPolicies) && iam.iamIntegration != nil {
return iam.authorizeWithIAM(r, identity, action, bucket, object)
@@ -1942,11 +2016,25 @@ func (iam *IdentityAccessManagement) authorizeWithIAM(r *http.Request, identity
}
}
- // Create IAMIdentity for authorization
+ // Create IAMIdentity for authorization — copy PolicyNames to avoid mutating shared identity
+ policyNames := make([]string, len(identity.PolicyNames))
+ copy(policyNames, identity.PolicyNames)
+
+ // Include policies inherited from user's groups
+ iam.m.RLock()
+ if groupNames, ok := iam.userGroups[identity.Name]; ok {
+ for _, gn := range groupNames {
+ if g, exists := iam.groups[gn]; exists && !g.Disabled {
+ policyNames = append(policyNames, g.PolicyNames...)
+ }
+ }
+ }
+ iam.m.RUnlock()
+
iamIdentity := &IAMIdentity{
Name: identity.Name,
Account: identity.Account,
- PolicyNames: identity.PolicyNames,
+ PolicyNames: policyNames,
Claims: identity.Claims, // Copy claims for policy variable substitution
}
@@ -2015,6 +2103,66 @@ func (iam *IdentityAccessManagement) DeletePolicy(name string) error {
return nil
}
+func (iam *IdentityAccessManagement) PutGroup(group *iam_pb.Group) error {
+ if group == nil {
+ return fmt.Errorf("put group failed: nil group")
+ }
+ if group.Name == "" {
+ return fmt.Errorf("put group failed: empty group name")
+ }
+ glog.V(1).Infof("IAM: put group %s", group.Name)
+
+ iam.m.Lock()
+ defer iam.m.Unlock()
+
+ // Remove old reverse index entries for this group
+ if old, ok := iam.groups[group.Name]; ok && !old.Disabled {
+ for _, member := range old.Members {
+ iam.removeUserGroupLocked(member, group.Name)
+ }
+ }
+
+ iam.groups[group.Name] = group
+
+ // Add new reverse index entries if group is enabled
+ if !group.Disabled {
+ for _, member := range group.Members {
+ iam.userGroups[member] = append(iam.userGroups[member], group.Name)
+ }
+ }
+
+ return nil
+}
+
+func (iam *IdentityAccessManagement) RemoveGroup(groupName string) {
+ glog.V(1).Infof("IAM: remove group %s", groupName)
+
+ iam.m.Lock()
+ defer iam.m.Unlock()
+
+ if g, ok := iam.groups[groupName]; ok && !g.Disabled {
+ for _, member := range g.Members {
+ iam.removeUserGroupLocked(member, groupName)
+ }
+ }
+ delete(iam.groups, groupName)
+}
+
+// removeUserGroupLocked removes a group from a user's group list.
+// Must be called with iam.m held.
+func (iam *IdentityAccessManagement) removeUserGroupLocked(username, groupName string) {
+ groups := iam.userGroups[username]
+ for i, g := range groups {
+ if g == groupName {
+ iam.userGroups[username] = append(groups[:i], groups[i+1:]...)
+ if len(iam.userGroups[username]) == 0 {
+ delete(iam.userGroups, username)
+ }
+ return
+ }
+ }
+}
+
// ensureIAMPolicyEngine lazily initializes the shared IAM policy engine.
// Must be called with iam.m held.
func (iam *IdentityAccessManagement) ensureIAMPolicyEngine() {
diff --git a/weed/s3api/auth_credentials_subscribe.go b/weed/s3api/auth_credentials_subscribe.go
index 30aad4fcb..e0eb15554 100644
--- a/weed/s3api/auth_credentials_subscribe.go
+++ b/weed/s3api/auth_credentials_subscribe.go
@@ -19,7 +19,9 @@ func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, p
message := resp.EventNotification
- // For rename/move operations, NewParentPath contains the destination directory
+ // For rename/move operations, NewParentPath contains the destination directory.
+ // We process both source and destination dirs so moves out of watched
+ // directories (e.g., IAM config dirs) are not missed.
dir := resp.Directory
if message.NewParentPath != "" {
dir = message.NewParentPath
@@ -31,6 +33,22 @@ func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, p
_ = s3a.onIamConfigChange(dir, message.OldEntry, message.NewEntry)
_ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, message.NewEntry)
+ // For moves across directories, replay a delete event for the source directory
+ if message.NewParentPath != "" && resp.Directory != message.NewParentPath {
+ _ = s3a.onBucketMetadataChange(resp.Directory, message.OldEntry, nil)
+ _ = s3a.onIamConfigChange(resp.Directory, message.OldEntry, nil)
+ _ = s3a.onCircuitBreakerConfigChange(resp.Directory, message.OldEntry, nil)
+ }
+
+ // For same-directory renames, replay a delete event for the old name
+ // so handlers can clean up stale state (e.g., old bucket names)
+ if message.OldEntry != nil && message.NewEntry != nil &&
+ (message.NewParentPath == "" || message.NewParentPath == resp.Directory) &&
+ message.OldEntry.Name != message.NewEntry.Name {
+ _ = s3a.onBucketMetadataChange(dir, message.OldEntry, nil)
+ _ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, nil)
+ }
+
return nil
}
@@ -93,8 +111,9 @@ func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry,
isIdentityDir := dir == filer.IamConfigDirectory+"/identities" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/identities/")
isPolicyDir := dir == filer.IamConfigDirectory+"/policies" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/policies/")
isServiceAccountDir := dir == filer.IamConfigDirectory+"/service_accounts" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/service_accounts/")
+ isGroupDir := dir == filer.IamConfigDirectory+"/groups" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/groups/")
- if isIdentityDir || isPolicyDir || isServiceAccountDir {
+ if isIdentityDir || isPolicyDir || isServiceAccountDir || isGroupDir {
// For multiple-file mode, any change in these directories should trigger a full reload
// from the credential manager (which handles the details of loading from multiple files).
if err := reloadIamConfig(dir); err != nil {
diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go
index 5b65382be..4dc3d420c 100644
--- a/weed/s3api/s3api_embedded_iam.go
+++ b/weed/s3api/s3api_embedded_iam.go
@@ -113,6 +113,18 @@ type (
iamListServiceAccountsResponse = iamlib.ListServiceAccountsResponse
iamGetServiceAccountResponse = iamlib.GetServiceAccountResponse
iamUpdateServiceAccountResponse = iamlib.UpdateServiceAccountResponse
+ // Group response types
+ iamCreateGroupResponse = iamlib.CreateGroupResponse
+ iamDeleteGroupResponse = iamlib.DeleteGroupResponse
+ iamUpdateGroupResponse = iamlib.UpdateGroupResponse
+ iamGetGroupResponse = iamlib.GetGroupResponse
+ iamListGroupsResponse = iamlib.ListGroupsResponse
+ iamAddUserToGroupResponse = iamlib.AddUserToGroupResponse
+ iamRemoveUserFromGroupResponse = iamlib.RemoveUserFromGroupResponse
+ iamAttachGroupPolicyResponse = iamlib.AttachGroupPolicyResponse
+ iamDetachGroupPolicyResponse = iamlib.DetachGroupPolicyResponse
+ iamListAttachedGroupPoliciesResponse = iamlib.ListAttachedGroupPoliciesResponse
+ iamListGroupsForUserResponse = iamlib.ListGroupsForUserResponse
)
// Helper function wrappers using shared package
@@ -304,6 +316,15 @@ func (e *EmbeddedIamApi) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName s
}
}
s3cfg.Identities = append(s3cfg.Identities[:i], s3cfg.Identities[i+1:]...)
+ // Remove user from all groups
+ for _, g := range s3cfg.Groups {
+ for j, m := range g.Members {
+ if m == userName {
+ g.Members = append(g.Members[:j], g.Members[j+1:]...)
+ break
+ }
+ }
+ }
return resp, nil
}
}
@@ -327,17 +348,51 @@ func (e *EmbeddedIamApi) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values url
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
- }
+ if newUserName == "" {
+ return resp, nil
+ }
+
+ // Find the source identity first
+ var sourceIdent *iam_pb.Identity
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == userName {
+ sourceIdent = ident
+ break
}
- } else {
+ }
+ if sourceIdent == nil {
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
+ }
+
+ // No-op if renaming to the same name
+ if newUserName == userName {
return resp, nil
}
- return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)}
+
+ // Check for name collision before renaming
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == newUserName {
+ return resp, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("user %s already exists", newUserName)}
+ }
+ }
+
+ sourceIdent.Name = newUserName
+ // Update group membership references
+ for _, g := range s3cfg.Groups {
+ for j, m := range g.Members {
+ if m == userName {
+ g.Members[j] = newUserName
+ break
+ }
+ }
+ }
+ // Update service account parent references
+ for _, sa := range s3cfg.ServiceAccounts {
+ if sa.ParentUser == userName {
+ sa.ParentUser = newUserName
+ }
+ }
+ return resp, nil
}
// CreateAccessKey creates an access key for a user.
@@ -481,6 +536,28 @@ func (e *EmbeddedIamApi) DeletePolicy(ctx context.Context, values url.Values) (*
}
}
}
+ // Check if policy is attached to any group
+ groupNames, err := e.credentialManager.ListGroups(ctx)
+ if err != nil {
+ return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
+ }
+ for _, gn := range groupNames {
+ g, err := e.credentialManager.GetGroup(ctx, gn)
+ if err != nil {
+ if errors.Is(err, credential.ErrGroupNotFound) {
+ continue
+ }
+ return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to get group %s: %w", gn, err)}
+ }
+ for _, pn := range g.PolicyNames {
+ if pn == policyName {
+ return resp, &iamError{
+ Code: iam.ErrCodeDeleteConflictException,
+ Error: fmt.Errorf("policy %s is attached to group %s", policyName, gn),
+ }
+ }
+ }
+ }
if err := e.credentialManager.DeletePolicy(ctx, policyName); err != nil {
return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err}
}
@@ -1405,6 +1482,275 @@ func (e *EmbeddedIamApi) UpdateServiceAccount(s3cfg *iam_pb.S3ApiConfiguration,
return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("service account %s not found", saId)}
}
+// Group Management Handlers
+
+func (e *EmbeddedIamApi) CreateGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamCreateGroupResponse, *iamError) {
+ resp := &iamCreateGroupResponse{}
+ groupName := values.Get("GroupName")
+ if groupName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ return resp, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("group %s already exists", groupName)}
+ }
+ }
+ s3cfg.Groups = append(s3cfg.Groups, &iam_pb.Group{Name: groupName})
+ resp.CreateGroupResult.Group.GroupName = &groupName
+ return resp, nil
+}
+
+func (e *EmbeddedIamApi) DeleteGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamDeleteGroupResponse, *iamError) {
+ resp := &iamDeleteGroupResponse{}
+ groupName := values.Get("GroupName")
+ if groupName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ for i, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ if len(g.Members) > 0 {
+ return resp, &iamError{Code: iam.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete group %s: group has %d member(s). Remove all members first", groupName, len(g.Members))}
+ }
+ if len(g.PolicyNames) > 0 {
+ return resp, &iamError{Code: iam.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete group %s: group has %d attached policy(ies). Detach all policies first", groupName, len(g.PolicyNames))}
+ }
+ s3cfg.Groups = append(s3cfg.Groups[:i], s3cfg.Groups[i+1:]...)
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (e *EmbeddedIamApi) UpdateGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamUpdateGroupResponse, *iamError) {
+ resp := &iamUpdateGroupResponse{}
+ groupName := values.Get("GroupName")
+ if groupName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ if disabled := values.Get("Disabled"); disabled != "" {
+ if disabled != "true" && disabled != "false" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("Disabled must be 'true' or 'false'")}
+ }
+ g.Disabled = disabled == "true"
+ }
+ if newName := values.Get("NewGroupName"); newName != "" && newName != g.Name {
+ for _, other := range s3cfg.Groups {
+ if other.Name == newName {
+ return resp, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("group %s already exists", newName)}
+ }
+ }
+ g.Name = newName
+ }
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (e *EmbeddedIamApi) GetGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamGetGroupResponse, *iamError) {
+ resp := &iamGetGroupResponse{}
+ groupName := values.Get("GroupName")
+ if groupName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ resp.GetGroupResult.Group.GroupName = &g.Name
+ for _, member := range g.Members {
+ memberName := member
+ resp.GetGroupResult.Users = append(resp.GetGroupResult.Users, &iam.User{UserName: &memberName})
+ }
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (e *EmbeddedIamApi) ListGroups(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) *iamListGroupsResponse {
+ resp := &iamListGroupsResponse{}
+ for _, g := range s3cfg.Groups {
+ name := g.Name
+ resp.ListGroupsResult.Groups = append(resp.ListGroupsResult.Groups, &iam.Group{GroupName: &name})
+ }
+ return resp
+}
+
+func (e *EmbeddedIamApi) AddUserToGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamAddUserToGroupResponse, *iamError) {
+ resp := &iamAddUserToGroupResponse{}
+ groupName := values.Get("GroupName")
+ userName := values.Get("UserName")
+ if groupName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ if userName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")}
+ }
+ // Verify user exists
+ userFound := false
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == userName {
+ userFound = true
+ break
+ }
+ }
+ if !userFound {
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s does not exist", userName)}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ // Check if already a member (idempotent)
+ for _, m := range g.Members {
+ if m == userName {
+ return resp, nil
+ }
+ }
+ g.Members = append(g.Members, userName)
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (e *EmbeddedIamApi) RemoveUserFromGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamRemoveUserFromGroupResponse, *iamError) {
+ resp := &iamRemoveUserFromGroupResponse{}
+ groupName := values.Get("GroupName")
+ userName := values.Get("UserName")
+ if groupName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ if userName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ for i, m := range g.Members {
+ if m == userName {
+ g.Members = append(g.Members[:i], g.Members[i+1:]...)
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s is not a member of group %s", userName, groupName)}
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (e *EmbeddedIamApi) AttachGroupPolicy(ctx context.Context, s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamAttachGroupPolicyResponse, *iamError) {
+ resp := &iamAttachGroupPolicyResponse{}
+ groupName := values.Get("GroupName")
+ policyArn := values.Get("PolicyArn")
+ if groupName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ policyName, err := iamPolicyNameFromArn(policyArn)
+ if err != nil {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
+ }
+ // Verify policy exists via credential manager
+ if e.credentialManager == nil {
+ return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("credential manager not available to validate policy %s", policyName)}
+ }
+ policy, pErr := e.credentialManager.GetPolicy(ctx, policyName)
+ if pErr != nil {
+ return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to look up policy %s: %w", policyName, pErr)}
+ }
+ if policy == nil {
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ // Check if already attached (idempotent)
+ for _, p := range g.PolicyNames {
+ if p == policyName {
+ return resp, nil
+ }
+ }
+ g.PolicyNames = append(g.PolicyNames, policyName)
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (e *EmbeddedIamApi) DetachGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamDetachGroupPolicyResponse, *iamError) {
+ resp := &iamDetachGroupPolicyResponse{}
+ groupName := values.Get("GroupName")
+ policyArn := values.Get("PolicyArn")
+ if groupName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ policyName, err := iamPolicyNameFromArn(policyArn)
+ if err != nil {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ for i, p := range g.PolicyNames {
+ if p == policyName {
+ g.PolicyNames = append(g.PolicyNames[:i], g.PolicyNames[i+1:]...)
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s is not attached to group %s", policyName, groupName)}
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (e *EmbeddedIamApi) ListAttachedGroupPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamListAttachedGroupPoliciesResponse, *iamError) {
+ resp := &iamListAttachedGroupPoliciesResponse{}
+ groupName := values.Get("GroupName")
+ if groupName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")}
+ }
+ for _, g := range s3cfg.Groups {
+ if g.Name == groupName {
+ for _, policyName := range g.PolicyNames {
+ pn := policyName
+ policyArn := fmt.Sprintf("arn:aws:iam:::policy/%s", policyName)
+ resp.ListAttachedGroupPoliciesResult.AttachedPolicies = append(resp.ListAttachedGroupPoliciesResult.AttachedPolicies, &iam.AttachedPolicy{
+ PolicyName: &pn,
+ PolicyArn: &policyArn,
+ })
+ }
+ return resp, nil
+ }
+ }
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)}
+}
+
+func (e *EmbeddedIamApi) ListGroupsForUser(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamListGroupsForUserResponse, *iamError) {
+ resp := &iamListGroupsForUserResponse{}
+ userName := values.Get("UserName")
+ if userName == "" {
+ return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")}
+ }
+ // Verify user exists
+ userFound := false
+ for _, ident := range s3cfg.Identities {
+ if ident.Name == userName {
+ userFound = true
+ break
+ }
+ }
+ if !userFound {
+ return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s does not exist", userName)}
+ }
+ // Build from s3cfg.Groups for consistency with freshly loaded config
+ for _, g := range s3cfg.Groups {
+ for _, m := range g.Members {
+ if m == userName {
+ name := g.Name
+ resp.ListGroupsForUserResult.Groups = append(resp.ListGroupsForUserResult.Groups, &iam.Group{GroupName: &name})
+ break
+ }
+ }
+ }
+ return resp, nil
+}
+
// 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."
@@ -1558,7 +1904,8 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s
action := values.Get("Action")
if e.readOnly {
switch action {
- case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListPolicyVersions", "GetPolicyVersion", "ListServiceAccounts", "GetServiceAccount":
+ case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListPolicyVersions", "GetPolicyVersion", "ListServiceAccounts", "GetServiceAccount",
+ "GetGroup", "ListGroups", "ListAttachedGroupPolicies", "ListGroupsForUser":
// Allowed read-only actions
default:
return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code, Error: fmt.Errorf("IAM write operations are disabled on this server")}
@@ -1745,6 +2092,73 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s
if iamErr != nil {
return nil, iamErr
}
+ // Group actions
+ case "CreateGroup":
+ var iamErr *iamError
+ response, iamErr = e.CreateGroup(s3cfg, values)
+ if iamErr != nil {
+ return nil, iamErr
+ }
+ case "DeleteGroup":
+ var iamErr *iamError
+ response, iamErr = e.DeleteGroup(s3cfg, values)
+ if iamErr != nil {
+ return nil, iamErr
+ }
+ case "UpdateGroup":
+ var iamErr *iamError
+ response, iamErr = e.UpdateGroup(s3cfg, values)
+ if iamErr != nil {
+ return nil, iamErr
+ }
+ case "GetGroup":
+ var iamErr *iamError
+ response, iamErr = e.GetGroup(s3cfg, values)
+ if iamErr != nil {
+ return nil, iamErr
+ }
+ changed = false
+ case "ListGroups":
+ response = e.ListGroups(s3cfg, values)
+ changed = false
+ case "AddUserToGroup":
+ var iamErr *iamError
+ response, iamErr = e.AddUserToGroup(s3cfg, values)
+ if iamErr != nil {
+ return nil, iamErr
+ }
+ case "RemoveUserFromGroup":
+ var iamErr *iamError
+ response, iamErr = e.RemoveUserFromGroup(s3cfg, values)
+ if iamErr != nil {
+ return nil, iamErr
+ }
+ case "AttachGroupPolicy":
+ var iamErr *iamError
+ response, iamErr = e.AttachGroupPolicy(ctx, s3cfg, values)
+ if iamErr != nil {
+ return nil, iamErr
+ }
+ case "DetachGroupPolicy":
+ var iamErr *iamError
+ response, iamErr = e.DetachGroupPolicy(s3cfg, values)
+ if iamErr != nil {
+ return nil, iamErr
+ }
+ case "ListAttachedGroupPolicies":
+ var iamErr *iamError
+ response, iamErr = e.ListAttachedGroupPolicies(s3cfg, values)
+ if iamErr != nil {
+ return nil, iamErr
+ }
+ changed = false
+ case "ListGroupsForUser":
+ var iamErr *iamError
+ response, iamErr = e.ListGroupsForUser(s3cfg, values)
+ if iamErr != nil {
+ return nil, iamErr
+ }
+ changed = false
default:
return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrNotImplemented).Code, Error: errors.New(s3err.GetAPIError(s3err.ErrNotImplemented).Description)}
}
diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go
index 065a05d39..f969582bc 100644
--- a/weed/s3api/s3api_server.go
+++ b/weed/s3api/s3api_server.go
@@ -276,6 +276,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl
filer.IamConfigDirectory + "/identities",
filer.IamConfigDirectory + "/policies",
filer.IamConfigDirectory + "/service_accounts",
+ filer.IamConfigDirectory + "/groups",
})
// Start bucket size metrics collection in background
diff --git a/weed/s3api/s3api_server_grpc.go b/weed/s3api/s3api_server_grpc.go
index 1bd48b307..2aae60b74 100644
--- a/weed/s3api/s3api_server_grpc.go
+++ b/weed/s3api/s3api_server_grpc.go
@@ -92,6 +92,33 @@ func (s3a *S3ApiServer) GetPolicy(ctx context.Context, req *iam_pb.GetPolicyRequ
}, nil
}
+func (s3a *S3ApiServer) PutGroup(ctx context.Context, req *iam_pb.PutGroupRequest) (*iam_pb.PutGroupResponse, error) {
+ if req.Group == nil {
+ return nil, status.Errorf(codes.InvalidArgument, "group is required")
+ }
+ glog.V(1).Infof("IAM: received group update for %s", req.Group.Name)
+ if s3a.iam == nil {
+ return nil, status.Errorf(codes.Internal, "IAM not initialized")
+ }
+ if err := s3a.iam.PutGroup(req.Group); err != nil {
+ glog.Errorf("failed to update group cache for %s: %v", req.Group.Name, err)
+ return nil, status.Errorf(codes.Internal, "failed to update group cache: %v", err)
+ }
+ return &iam_pb.PutGroupResponse{}, nil
+}
+
+func (s3a *S3ApiServer) RemoveGroup(ctx context.Context, req *iam_pb.RemoveGroupRequest) (*iam_pb.RemoveGroupResponse, error) {
+ if req.GroupName == "" {
+ return nil, status.Errorf(codes.InvalidArgument, "group name is required")
+ }
+ glog.V(1).Infof("IAM: received group removal for %s", req.GroupName)
+ if s3a.iam == nil {
+ return nil, status.Errorf(codes.Internal, "IAM not initialized")
+ }
+ s3a.iam.RemoveGroup(req.GroupName)
+ return &iam_pb.RemoveGroupResponse{}, nil
+}
+
func (s3a *S3ApiServer) ListPolicies(ctx context.Context, req *iam_pb.ListPoliciesRequest) (*iam_pb.ListPoliciesResponse, error) {
resp := &iam_pb.ListPoliciesResponse{}
if s3a.iam == nil {