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..e5b05cc06
--- /dev/null
+++ b/test/s3/iam/s3_iam_group_test.go
@@ -0,0 +1,780 @@
+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/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),
+ })
+ assert.Error(t, err, "User without any policies should be denied")
+ })
+
+ 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
+ require.Eventually(t, func() bool {
+ _, err = userS3Client.ListObjects(&s3.ListObjectsInput{
+ Bucket: aws.String(bucketName),
+ })
+ return err != nil
+ }, 10*time.Second, 500*time.Millisecond, "User removed from group should be denied")
+ })
+}
+
+// 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
+ 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 disabled group should be denied access")
+ })
+
+ 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/group_management.go b/weed/admin/dash/group_management.go
new file mode 100644
index 000000000..dda8f7f6d
--- /dev/null
+++ b/weed/admin/dash/group_management.go
@@ -0,0 +1,246 @@
+package dash
+
+import (
+ "context"
+ "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 {
+ glog.V(1).Infof("Failed to get group %s: %v", name, err)
+ continue
+ }
+ 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/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..2b2c3ef32
--- /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 := h.getGroupsPageData(r)
+
+ 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 {
+ username := dash.UsernameFromContext(r.Context())
+ if username == "" {
+ username = "admin"
+ }
+
+ groups, err := h.adminServer.GetGroups(r.Context())
+ if err != nil {
+ glog.Errorf("Failed to get groups: %v", err)
+ return dash.GroupsPageData{
+ Username: username,
+ Groups: []dash.GroupData{},
+ LastUpdated: time.Now(),
+ }
+ }
+
+ 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(),
+ }
+}
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..fd729106a
--- /dev/null
+++ b/weed/admin/view/app/groups.templ
@@ -0,0 +1,439 @@
+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..ae9e3fbdb
--- /dev/null
+++ b/weed/admin/view/app/groups_templ.go
@@ -0,0 +1,248 @@
+// 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: `weed/admin/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: `weed/admin/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: `weed/admin/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: `weed/admin/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: `weed/admin/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, 15, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
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..2bb8a431b 100644
--- a/weed/admin/view/layout/layout_templ.go
+++ b/weed/admin/view/layout/layout_templ.go
@@ -65,7 +65,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 41, Col: 47}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 41, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -78,7 +78,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(username)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 74, Col: 73}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 74, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -100,7 +100,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -113,7 +113,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", isClusterPage))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 101, Col: 207}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 101, Col: 207}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -135,7 +135,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -157,7 +157,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var9).String())
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -170,7 +170,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", isStoragePage))
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 126, Col: 207}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 126, Col: 207}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -192,13 +192,13 @@ func Layout(view ViewContext, content templ.Component) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var12).String())
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 1, Col: 0}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
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: `weed/admin/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: `weed/admin/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: `weed/admin/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: `weed/admin/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: `weed/admin/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: `weed/admin/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..22ae6e230
--- /dev/null
+++ b/weed/credential/filer_etc/filer_etc_group.go
@@ -0,0 +1,178 @@
+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")
+ }
+ 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..141890747 100644
--- a/weed/credential/propagating_store.go
+++ b/weed/credential/propagating_store.go
@@ -385,3 +385,34 @@ func (s *PropagatingCredentialStore) DeleteServiceAccount(ctx context.Context, i
})
return nil
}
+
+// Group methods do not call propagateChange because there are no group-specific
+// gRPC RPCs in the S3 cache protocol. Group changes are propagated to S3 servers
+// via the filer subscription mechanism (watching /etc/iam/groups/ directory).
+
+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)
+ }
+ return s.CredentialStore.CreateGroup(ctx, group)
+}
+
+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)
+ return s.CredentialStore.DeleteGroup(ctx, groupName)
+}
+
+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)
+ }
+ return s.CredentialStore.UpdateGroup(ctx, group)
+}
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..a989d1087 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 {
diff --git a/weed/pb/iam_pb/iam.pb.go b/weed/pb/iam_pb/iam.pb.go
index 243abda7e..7232d2762 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,7 @@ 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}
}
var File_iam_proto protoreflect.FileDescriptor
@@ -2764,14 +2840,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" +
@@ -2892,7 +2974,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, 59)
var file_iam_proto_goTypes = []any{
(*GetConfigurationRequest)(nil), // 0: iam_pb.GetConfigurationRequest
(*GetConfigurationResponse)(nil), // 1: iam_pb.GetConfigurationResponse
@@ -2923,104 +3005,106 @@ 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
}
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
+ 0, // 23: iam_pb.SeaweedIdentityAccessManagement.GetConfiguration:input_type -> iam_pb.GetConfigurationRequest
+ 2, // 24: iam_pb.SeaweedIdentityAccessManagement.PutConfiguration:input_type -> iam_pb.PutConfigurationRequest
+ 4, // 25: iam_pb.SeaweedIdentityAccessManagement.CreateUser:input_type -> iam_pb.CreateUserRequest
+ 6, // 26: iam_pb.SeaweedIdentityAccessManagement.GetUser:input_type -> iam_pb.GetUserRequest
+ 8, // 27: iam_pb.SeaweedIdentityAccessManagement.UpdateUser:input_type -> iam_pb.UpdateUserRequest
+ 10, // 28: iam_pb.SeaweedIdentityAccessManagement.DeleteUser:input_type -> iam_pb.DeleteUserRequest
+ 12, // 29: iam_pb.SeaweedIdentityAccessManagement.ListUsers:input_type -> iam_pb.ListUsersRequest
+ 14, // 30: iam_pb.SeaweedIdentityAccessManagement.CreateAccessKey:input_type -> iam_pb.CreateAccessKeyRequest
+ 16, // 31: iam_pb.SeaweedIdentityAccessManagement.DeleteAccessKey:input_type -> iam_pb.DeleteAccessKeyRequest
+ 18, // 32: iam_pb.SeaweedIdentityAccessManagement.GetUserByAccessKey:input_type -> iam_pb.GetUserByAccessKeyRequest
+ 34, // 33: iam_pb.SeaweedIdentityAccessManagement.PutPolicy:input_type -> iam_pb.PutPolicyRequest
+ 36, // 34: iam_pb.SeaweedIdentityAccessManagement.GetPolicy:input_type -> iam_pb.GetPolicyRequest
+ 38, // 35: iam_pb.SeaweedIdentityAccessManagement.ListPolicies:input_type -> iam_pb.ListPoliciesRequest
+ 40, // 36: iam_pb.SeaweedIdentityAccessManagement.DeletePolicy:input_type -> iam_pb.DeletePolicyRequest
+ 43, // 37: iam_pb.SeaweedIdentityAccessManagement.CreateServiceAccount:input_type -> iam_pb.CreateServiceAccountRequest
+ 45, // 38: iam_pb.SeaweedIdentityAccessManagement.UpdateServiceAccount:input_type -> iam_pb.UpdateServiceAccountRequest
+ 47, // 39: iam_pb.SeaweedIdentityAccessManagement.DeleteServiceAccount:input_type -> iam_pb.DeleteServiceAccountRequest
+ 49, // 40: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccount:input_type -> iam_pb.GetServiceAccountRequest
+ 51, // 41: iam_pb.SeaweedIdentityAccessManagement.ListServiceAccounts:input_type -> iam_pb.ListServiceAccountsRequest
+ 53, // 42: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccountByAccessKey:input_type -> iam_pb.GetServiceAccountByAccessKeyRequest
+ 1, // 43: iam_pb.SeaweedIdentityAccessManagement.GetConfiguration:output_type -> iam_pb.GetConfigurationResponse
+ 3, // 44: iam_pb.SeaweedIdentityAccessManagement.PutConfiguration:output_type -> iam_pb.PutConfigurationResponse
+ 5, // 45: iam_pb.SeaweedIdentityAccessManagement.CreateUser:output_type -> iam_pb.CreateUserResponse
+ 7, // 46: iam_pb.SeaweedIdentityAccessManagement.GetUser:output_type -> iam_pb.GetUserResponse
+ 9, // 47: iam_pb.SeaweedIdentityAccessManagement.UpdateUser:output_type -> iam_pb.UpdateUserResponse
+ 11, // 48: iam_pb.SeaweedIdentityAccessManagement.DeleteUser:output_type -> iam_pb.DeleteUserResponse
+ 13, // 49: iam_pb.SeaweedIdentityAccessManagement.ListUsers:output_type -> iam_pb.ListUsersResponse
+ 15, // 50: iam_pb.SeaweedIdentityAccessManagement.CreateAccessKey:output_type -> iam_pb.CreateAccessKeyResponse
+ 17, // 51: iam_pb.SeaweedIdentityAccessManagement.DeleteAccessKey:output_type -> iam_pb.DeleteAccessKeyResponse
+ 19, // 52: iam_pb.SeaweedIdentityAccessManagement.GetUserByAccessKey:output_type -> iam_pb.GetUserByAccessKeyResponse
+ 35, // 53: iam_pb.SeaweedIdentityAccessManagement.PutPolicy:output_type -> iam_pb.PutPolicyResponse
+ 37, // 54: iam_pb.SeaweedIdentityAccessManagement.GetPolicy:output_type -> iam_pb.GetPolicyResponse
+ 39, // 55: iam_pb.SeaweedIdentityAccessManagement.ListPolicies:output_type -> iam_pb.ListPoliciesResponse
+ 41, // 56: iam_pb.SeaweedIdentityAccessManagement.DeletePolicy:output_type -> iam_pb.DeletePolicyResponse
+ 44, // 57: iam_pb.SeaweedIdentityAccessManagement.CreateServiceAccount:output_type -> iam_pb.CreateServiceAccountResponse
+ 46, // 58: iam_pb.SeaweedIdentityAccessManagement.UpdateServiceAccount:output_type -> iam_pb.UpdateServiceAccountResponse
+ 48, // 59: iam_pb.SeaweedIdentityAccessManagement.DeleteServiceAccount:output_type -> iam_pb.DeleteServiceAccountResponse
+ 50, // 60: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccount:output_type -> iam_pb.GetServiceAccountResponse
+ 52, // 61: iam_pb.SeaweedIdentityAccessManagement.ListServiceAccounts:output_type -> iam_pb.ListServiceAccountsResponse
+ 54, // 62: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccountByAccessKey:output_type -> iam_pb.GetServiceAccountByAccessKeyResponse
+ 43, // [43:63] is the sub-list for method output_type
+ 23, // [23:43] is the sub-list for method input_type
+ 23, // [23:23] is the sub-list for extension type_name
+ 23, // [23:23] is the sub-list for extension extendee
+ 0, // [0:23] is the sub-list for field type_name
}
func init() { file_iam_proto_init() }
@@ -3034,7 +3118,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: 59,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go
index ae7b48be3..5d2a2da22 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
@@ -911,6 +925,18 @@ func (iam *IdentityAccessManagement) MergeS3ApiConfiguration(config *iam_pb.S3Ap
policies[policy.Name] = policy
}
+ // Process groups from dynamic config (groups are always dynamic, never in static s3.config)
+ 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.m.Lock()
// atomically switch
iam.identities = identities
@@ -920,6 +946,8 @@ func (iam *IdentityAccessManagement) MergeS3ApiConfiguration(config *iam_pb.S3Ap
iam.nameToIdentity = nameToIdentity
iam.accessKeyIdent = accessKeyIdent
iam.policies = policies
+ 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 +1865,21 @@ 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]
+ groupMap := iam.groups
iam.m.RUnlock()
+ // Collect all policy names: user policies + group policies
+ if len(identity.PolicyNames) == 0 && len(groupNames) == 0 {
+ return false
+ }
+
if engine == nil {
return false
}
@@ -1858,15 +1893,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 +1912,23 @@ func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identi
}
}
+ // Evaluate policies from user's groups (skip disabled groups)
+ for _, gName := range groupNames {
+ g, ok := groupMap[gName]
+ if !ok || g.Disabled {
+ continue
+ }
+ for _, policyName := range g.PolicyNames {
+ result := engine.EvaluatePolicy(policyName, evalArgs)
+ if result == policy_engine.PolicyResultDeny {
+ return false
+ }
+ if result == policy_engine.PolicyResultAllow {
+ explicitAllow = true
+ }
+ }
+ }
+
return explicitAllow
}
@@ -1894,7 +1948,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 +2006,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
}
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..0d558ae0f 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,274 @@ 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 {
+ 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 +1903,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 +2091,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)}
}