diff --git a/.github/workflows/s3-iam-tests.yml b/.github/workflows/s3-iam-tests.yml index 841d32514..80010b782 100644 --- a/.github/workflows/s3-iam-tests.yml +++ b/.github/workflows/s3-iam-tests.yml @@ -5,6 +5,8 @@ on: paths: - 'weed/iam/**' - 'weed/s3api/**' + - 'weed/credential/**' + - 'weed/pb/**' - 'test/s3/iam/**' - '.github/workflows/s3-iam-tests.yml' push: @@ -12,6 +14,8 @@ on: paths: - 'weed/iam/**' - 'weed/s3api/**' + - 'weed/credential/**' + - 'weed/pb/**' - 'test/s3/iam/**' - '.github/workflows/s3-iam-tests.yml' @@ -80,7 +84,7 @@ jobs: timeout-minutes: 25 strategy: matrix: - test-type: ["basic", "advanced", "policy-enforcement"] + test-type: ["basic", "advanced", "policy-enforcement", "group"] steps: - name: Check out code @@ -117,7 +121,7 @@ jobs: "basic") echo "Running basic IAM functionality tests..." make clean setup start-services wait-for-services - go test -v -timeout 15m -run "TestS3IAMAuthentication|TestS3IAMBasicWorkflow|TestS3IAMTokenValidation|TestIAM" ./... + go test -v -timeout 15m -run "TestS3IAMAuthentication|TestS3IAMBasicWorkflow|TestS3IAMTokenValidation|TestIAMUserManagement|TestIAMAccessKeyManagement|TestIAMPolicyManagement" ./... ;; "advanced") echo "Running advanced IAM feature tests..." @@ -129,6 +133,11 @@ jobs: make clean setup start-services wait-for-services go test -v -timeout 15m -run "TestS3IAMPolicyEnforcement|TestS3IAMBucketPolicy|TestS3IAMContextual" ./... ;; + "group") + echo "Running IAM group management tests..." + make clean setup start-services wait-for-services + go test -v -timeout 15m -run "TestIAMGroup" ./... + ;; *) echo "Unknown test type: ${{ matrix.test-type }}" exit 1 diff --git a/test/s3/iam/Makefile b/test/s3/iam/Makefile index 6eb5b0db8..6dbb54299 100644 --- a/test/s3/iam/Makefile +++ b/test/s3/iam/Makefile @@ -185,6 +185,9 @@ test-context: ## Test only contextual policy enforcement test-presigned: ## Test only presigned URL integration go test -v -run TestS3IAMPresignedURLIntegration ./... +test-group: ## Run IAM group management tests + go test -v -run "TestIAMGroup" ./... + test-sts: ## Run all STS tests go test -v -run "TestSTS" ./... @@ -263,7 +266,7 @@ docker-build: ## Build custom SeaweedFS image for Docker tests # All PHONY targets .PHONY: test test-quick run-tests setup start-services stop-services wait-for-services clean logs status debug -.PHONY: test-auth test-policy test-expiration test-multipart test-bucket-policy test-context test-presigned test-sts test-sts-assume-role test-sts-ldap +.PHONY: test-auth test-policy test-expiration test-multipart test-bucket-policy test-context test-presigned test-group test-sts test-sts-assume-role test-sts-ldap .PHONY: benchmark ci watch install-deps docker-test docker-up docker-down docker-logs docker-build .PHONY: test-distributed test-performance test-stress test-versioning-stress test-keycloak-full test-all-previously-skipped setup-all-tests help-advanced diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go new file mode 100644 index 000000000..1043e7c95 --- /dev/null +++ b/test/s3/iam/s3_iam_group_test.go @@ -0,0 +1,792 @@ +package iam + +import ( + "encoding/xml" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIAMGroupLifecycle tests the full lifecycle of group management: +// CreateGroup, GetGroup, ListGroups, DeleteGroup +func TestIAMGroupLifecycle(t *testing.T) { + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + groupName := "test-group-lifecycle" + + t.Run("create_group", func(t *testing.T) { + resp, err := iamClient.CreateGroup(&iam.CreateGroupInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + assert.Equal(t, groupName, *resp.Group.GroupName) + }) + + t.Run("get_group", func(t *testing.T) { + resp, err := iamClient.GetGroup(&iam.GetGroupInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + assert.Equal(t, groupName, *resp.Group.GroupName) + }) + + t.Run("list_groups_contains_created", func(t *testing.T) { + resp, err := iamClient.ListGroups(&iam.ListGroupsInput{}) + require.NoError(t, err) + found := false + for _, g := range resp.Groups { + if *g.GroupName == groupName { + found = true + break + } + } + assert.True(t, found, "Created group should appear in ListGroups") + }) + + t.Run("create_duplicate_group_fails", func(t *testing.T) { + _, err := iamClient.CreateGroup(&iam.CreateGroupInput{ + GroupName: aws.String(groupName), + }) + assert.Error(t, err, "Creating a duplicate group should fail") + }) + + t.Run("delete_group", func(t *testing.T) { + _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + + // Verify it's gone + resp, err := iamClient.ListGroups(&iam.ListGroupsInput{}) + require.NoError(t, err) + for _, g := range resp.Groups { + assert.NotEqual(t, groupName, *g.GroupName, + "Deleted group should not appear in ListGroups") + } + }) + + t.Run("delete_nonexistent_group_fails", func(t *testing.T) { + _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{ + GroupName: aws.String("nonexistent-group-xyz"), + }) + assert.Error(t, err) + }) +} + +// TestIAMGroupMembership tests adding and removing users from groups +func TestIAMGroupMembership(t *testing.T) { + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + groupName := "test-group-members" + userName := "test-user-for-group" + + // Setup: create group and user + _, err = iamClient.CreateGroup(&iam.CreateGroupInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) + + _, err = iamClient.CreateUser(&iam.CreateUserInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + + t.Run("add_user_to_group", func(t *testing.T) { + _, err := iamClient.AddUserToGroup(&iam.AddUserToGroupInput{ + GroupName: aws.String(groupName), + UserName: aws.String(userName), + }) + require.NoError(t, err) + }) + + t.Run("get_group_shows_member", func(t *testing.T) { + resp, err := iamClient.GetGroup(&iam.GetGroupInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + found := false + for _, u := range resp.Users { + if *u.UserName == userName { + found = true + break + } + } + assert.True(t, found, "Added user should appear in GetGroup members") + }) + + t.Run("list_groups_for_user", func(t *testing.T) { + resp, err := iamClient.ListGroupsForUser(&iam.ListGroupsForUserInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + found := false + for _, g := range resp.Groups { + if *g.GroupName == groupName { + found = true + break + } + } + assert.True(t, found, "Group should appear in ListGroupsForUser") + }) + + t.Run("add_duplicate_member_is_idempotent", func(t *testing.T) { + _, err := iamClient.AddUserToGroup(&iam.AddUserToGroupInput{ + GroupName: aws.String(groupName), + UserName: aws.String(userName), + }) + // Should succeed (idempotent) or return a benign error + // AWS IAM allows duplicate add without error + assert.NoError(t, err) + }) + + t.Run("remove_user_from_group", func(t *testing.T) { + _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: aws.String(groupName), + UserName: aws.String(userName), + }) + require.NoError(t, err) + + // Verify removal + resp, err := iamClient.GetGroup(&iam.GetGroupInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + for _, u := range resp.Users { + assert.NotEqual(t, userName, *u.UserName, + "Removed user should not appear in group members") + } + }) +} + +// TestIAMGroupPolicyAttachment tests attaching and detaching policies from groups +func TestIAMGroupPolicyAttachment(t *testing.T) { + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + groupName := "test-group-policies" + policyName := "test-group-attach-policy" + policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:ListBucket","Resource":"*"}]}` + + // Setup: create group and policy + _, err = iamClient.CreateGroup(&iam.CreateGroupInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + + createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + policyArn := createPolicyResp.Policy.Arn + + // Cleanup in correct order: detach policy, delete group, delete policy + t.Cleanup(func() { + if _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: policyArn, + }); err != nil { + t.Logf("cleanup: failed to detach group policy: %v", err) + } + if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil { + t.Logf("cleanup: failed to delete group: %v", err) + } + if _, err := iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}); err != nil { + t.Logf("cleanup: failed to delete policy: %v", err) + } + }) + + t.Run("attach_group_policy", func(t *testing.T) { + _, err := iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: policyArn, + }) + require.NoError(t, err) + }) + + t.Run("list_attached_group_policies", func(t *testing.T) { + resp, err := iamClient.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + found := false + for _, p := range resp.AttachedPolicies { + if *p.PolicyName == policyName { + found = true + break + } + } + assert.True(t, found, "Attached policy should appear in ListAttachedGroupPolicies") + }) + + t.Run("detach_group_policy", func(t *testing.T) { + _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: policyArn, + }) + require.NoError(t, err) + + // Verify detachment + resp, err := iamClient.ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + for _, p := range resp.AttachedPolicies { + assert.NotEqual(t, policyName, *p.PolicyName, + "Detached policy should not appear in ListAttachedGroupPolicies") + } + }) +} + +// TestIAMGroupPolicyEnforcement tests that group policies are enforced during S3 operations. +// Creates a user with no direct policies, adds them to a group with S3 access, +// and verifies they can access S3 through the group policy. +func TestIAMGroupPolicyEnforcement(t *testing.T) { + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + groupName := "test-enforcement-group" + userName := "test-enforcement-user" + policyName := "test-enforcement-policy" + bucketName := "test-group-enforce-bucket" + policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"]}]}` + + // Create user + _, err = iamClient.CreateUser(&iam.CreateUserInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + + // Create access key for the user + keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + + accessKeyId := *keyResp.AccessKey.AccessKeyId + secretKey := *keyResp.AccessKey.SecretAccessKey + + // Create an S3 client with the user's credentials + userS3Client := createS3Client(t, accessKeyId, secretKey) + + // Create group + _, err = iamClient.CreateGroup(&iam.CreateGroupInput{ + GroupName: aws.String(groupName), + }) + require.NoError(t, err) + + // Create policy + createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + policyArn := createPolicyResp.Policy.Arn + + // Cleanup in correct order: remove user from group, detach policy, + // delete access key, delete user, delete group, delete policy + t.Cleanup(func() { + if _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: aws.String(groupName), + UserName: aws.String(userName), + }); err != nil { + t.Logf("cleanup: failed to remove user from group: %v", err) + } + if _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: policyArn, + }); err != nil { + t.Logf("cleanup: failed to detach group policy: %v", err) + } + if _, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + UserName: aws.String(userName), + AccessKeyId: keyResp.AccessKey.AccessKeyId, + }); err != nil { + t.Logf("cleanup: failed to delete access key: %v", err) + } + if _, err := iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil { + t.Logf("cleanup: failed to delete user: %v", err) + } + if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil { + t.Logf("cleanup: failed to delete group: %v", err) + } + if _, err := iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}); err != nil { + t.Logf("cleanup: failed to delete policy: %v", err) + } + }) + + // Register bucket cleanup on parent test with admin credentials + // (userS3Client may lack permissions by cleanup time) + adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + t.Cleanup(func() { + if _, err := adminS3.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("test-key"), + }); err != nil { + t.Logf("cleanup: failed to delete object: %v", err) + } + if _, err := adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}); err != nil { + t.Logf("cleanup: failed to delete bucket: %v", err) + } + }) + + t.Run("user_without_group_denied", func(t *testing.T) { + // User has no policies and is not in any group — should be denied + _, err := userS3Client.CreateBucket(&s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + }) + require.Error(t, err, "User without any policies should be denied") + awsErr, ok := err.(awserr.Error) + require.True(t, ok, "Expected awserr.Error") + assert.Equal(t, "AccessDenied", awsErr.Code()) + }) + + t.Run("user_with_group_policy_allowed", func(t *testing.T) { + // Attach policy to group + _, err := iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: policyArn, + }) + require.NoError(t, err) + + // Add user to group + _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{ + GroupName: aws.String(groupName), + UserName: aws.String(userName), + }) + require.NoError(t, err) + + // Wait for policy propagation, then create bucket + require.Eventually(t, func() bool { + _, err = userS3Client.CreateBucket(&s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + }) + return err == nil + }, 10*time.Second, 500*time.Millisecond, "User with group policy should be allowed") + + // Should also be able to put/get objects + _, err = userS3Client.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("test-key"), + Body: aws.ReadSeekCloser(strings.NewReader("test-data")), + }) + require.NoError(t, err, "User should be able to put objects through group policy") + }) + + t.Run("user_removed_from_group_denied", func(t *testing.T) { + // Remove user from group + _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: aws.String(groupName), + UserName: aws.String(userName), + }) + require.NoError(t, err) + + // Wait for policy propagation — user should now be denied + var lastErr error + require.Eventually(t, func() bool { + _, lastErr = userS3Client.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(bucketName), + }) + return lastErr != nil + }, 10*time.Second, 500*time.Millisecond, "User removed from group should be denied") + awsErr, ok := lastErr.(awserr.Error) + require.True(t, ok, "Expected awserr.Error") + assert.Equal(t, "AccessDenied", awsErr.Code()) + }) +} + +// TestIAMGroupDisabledPolicyEnforcement tests that disabled groups do not contribute policies. +// Uses the raw IAM API (callIAMAPI) since the AWS SDK doesn't support custom group status. +func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + if !isSeaweedFSRunning(t) { + t.Skip("SeaweedFS is not running at", TestIAMEndpoint) + } + + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + groupName := "test-disabled-group" + userName := "test-disabled-grp-user" + policyName := "test-disabled-grp-policy" + bucketName := "test-disabled-grp-bucket" + policyDoc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:*"],"Resource":["arn:aws:s3:::` + bucketName + `","arn:aws:s3:::` + bucketName + `/*"]}]}` + + // Create user, group, policy + _, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)}) + require.NoError(t, err) + + keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(userName)}) + require.NoError(t, err) + + _, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(groupName)}) + require.NoError(t, err) + + createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + + // Cleanup in correct order: remove user from group, detach policy, + // delete access key, delete user, delete group, delete policy + t.Cleanup(func() { + if _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: aws.String(groupName), UserName: aws.String(userName), + }); err != nil { + t.Logf("cleanup: failed to remove user from group: %v", err) + } + if _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: aws.String("arn:aws:iam:::policy/" + policyName), + }); err != nil { + t.Logf("cleanup: failed to detach group policy: %v", err) + } + if _, err := iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + UserName: aws.String(userName), AccessKeyId: keyResp.AccessKey.AccessKeyId, + }); err != nil { + t.Logf("cleanup: failed to delete access key: %v", err) + } + if _, err := iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil { + t.Logf("cleanup: failed to delete user: %v", err) + } + if _, err := iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}); err != nil { + t.Logf("cleanup: failed to delete group: %v", err) + } + if _, err := iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: createPolicyResp.Policy.Arn}); err != nil { + t.Logf("cleanup: failed to delete policy: %v", err) + } + }) + + // Setup: attach policy, add user, create bucket with admin + _, err = iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{ + GroupName: aws.String(groupName), PolicyArn: createPolicyResp.Policy.Arn, + }) + require.NoError(t, err) + + _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{ + GroupName: aws.String(groupName), UserName: aws.String(userName), + }) + require.NoError(t, err) + + userS3Client := createS3Client(t, *keyResp.AccessKey.AccessKeyId, *keyResp.AccessKey.SecretAccessKey) + + // Create bucket using admin first so we can test listing + adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + _, err = adminS3.CreateBucket(&s3.CreateBucketInput{Bucket: aws.String(bucketName)}) + require.NoError(t, err) + defer adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) + + t.Run("enabled_group_allows_access", func(t *testing.T) { + require.Eventually(t, func() bool { + _, err := userS3Client.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(bucketName), + }) + return err == nil + }, 10*time.Second, 500*time.Millisecond, "User in enabled group should have access") + }) + + t.Run("disabled_group_denies_access", func(t *testing.T) { + // Disable group via raw IAM API (no SDK support for this extension) + resp, err := callIAMAPIAuthenticated(t, framework, "UpdateGroup", url.Values{ + "GroupName": {groupName}, + "Disabled": {"true"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "UpdateGroup (disable) should return 200") + + // Wait for propagation — user should be denied + var lastErr error + require.Eventually(t, func() bool { + _, lastErr = userS3Client.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(bucketName), + }) + return lastErr != nil + }, 10*time.Second, 500*time.Millisecond, "User in disabled group should be denied access") + awsErr, ok := lastErr.(awserr.Error) + require.True(t, ok, "Expected awserr.Error") + assert.Equal(t, "AccessDenied", awsErr.Code()) + }) + + t.Run("re_enabled_group_restores_access", func(t *testing.T) { + // Re-enable the group + resp, err := callIAMAPIAuthenticated(t, framework, "UpdateGroup", url.Values{ + "GroupName": {groupName}, + "Disabled": {"false"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, "UpdateGroup (re-enable) should return 200") + + // Wait for propagation — user should have access again + require.Eventually(t, func() bool { + _, err = userS3Client.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(bucketName), + }) + return err == nil + }, 10*time.Second, 500*time.Millisecond, "User in re-enabled group should have access again") + }) +} + +// TestIAMGroupUserDeletionSideEffect tests that deleting a user removes them from all groups. +func TestIAMGroupUserDeletionSideEffect(t *testing.T) { + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + groupName := "test-deletion-group" + userName := "test-deletion-user" + + // Create group and user + _, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(groupName)}) + require.NoError(t, err) + defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) + + _, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)}) + require.NoError(t, err) + t.Cleanup(func() { + // Best-effort: user may already be deleted by the test + iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + }) + + // Add user to group + _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{ + GroupName: aws.String(groupName), + UserName: aws.String(userName), + }) + require.NoError(t, err) + + // Verify user is in group + getResp, err := iamClient.GetGroup(&iam.GetGroupInput{GroupName: aws.String(groupName)}) + require.NoError(t, err) + assert.Len(t, getResp.Users, 1, "Group should have 1 member before deletion") + + // Delete the user + _, err = iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + require.NoError(t, err) + + // Verify user was removed from the group + getResp, err = iamClient.GetGroup(&iam.GetGroupInput{GroupName: aws.String(groupName)}) + require.NoError(t, err) + assert.Empty(t, getResp.Users, "Group should have no members after user deletion") +} + +// TestIAMGroupMultipleGroups tests that a user can belong to multiple groups +// and inherits policies from all of them. +func TestIAMGroupMultipleGroups(t *testing.T) { + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + iamClient, err := framework.CreateIAMClientWithJWT("admin-user", "TestAdminRole") + require.NoError(t, err) + + group1 := "test-multi-group-1" + group2 := "test-multi-group-2" + userName := "test-multi-group-user" + + // Create two groups + _, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(group1)}) + require.NoError(t, err) + defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(group1)}) + + _, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(group2)}) + require.NoError(t, err) + defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(group2)}) + + // Create user + _, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)}) + require.NoError(t, err) + defer func() { + iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: aws.String(group1), UserName: aws.String(userName), + }) + iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: aws.String(group2), UserName: aws.String(userName), + }) + iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + }() + + // Add user to both groups + _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{ + GroupName: aws.String(group1), UserName: aws.String(userName), + }) + require.NoError(t, err) + + _, err = iamClient.AddUserToGroup(&iam.AddUserToGroupInput{ + GroupName: aws.String(group2), UserName: aws.String(userName), + }) + require.NoError(t, err) + + // Verify user appears in both groups + resp, err := iamClient.ListGroupsForUser(&iam.ListGroupsForUserInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + groupNames := make(map[string]bool) + for _, g := range resp.Groups { + groupNames[*g.GroupName] = true + } + assert.True(t, groupNames[group1], "User should be in group 1") + assert.True(t, groupNames[group2], "User should be in group 2") +} + +// --- Response types for raw IAM API calls --- + +type CreateGroupResponse struct { + XMLName xml.Name `xml:"CreateGroupResponse"` + CreateGroupResult struct { + Group struct { + GroupName string `xml:"GroupName"` + } `xml:"Group"` + } `xml:"CreateGroupResult"` +} + +type ListGroupsResponse struct { + XMLName xml.Name `xml:"ListGroupsResponse"` + ListGroupsResult struct { + Groups []struct { + GroupName string `xml:"GroupName"` + } `xml:"Groups>member"` + } `xml:"ListGroupsResult"` +} + +// callIAMAPIAuthenticated sends an authenticated raw IAM API request using the +// framework's JWT token. This is needed for custom extensions not in the AWS SDK +// (like UpdateGroup with Disabled parameter). +func callIAMAPIAuthenticated(_ *testing.T, framework *S3IAMTestFramework, action string, params url.Values) (*http.Response, error) { + params.Set("Action", action) + + req, err := http.NewRequest(http.MethodPost, TestIAMEndpoint+"/", + strings.NewReader(params.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + token, err := framework.generateSTSSessionToken("admin-user", "TestAdminRole", time.Hour, "", nil) + if err != nil { + return nil, err + } + + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: &BearerTokenTransport{Token: token}, + } + return client.Do(req) +} + +// TestIAMGroupRawAPI tests group operations using raw HTTP IAM API calls, +// verifying XML response format for group operations. +func TestIAMGroupRawAPI(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + if !isSeaweedFSRunning(t) { + t.Skip("SeaweedFS is not running at", TestIAMEndpoint) + } + + framework := NewS3IAMTestFramework(t) + defer framework.Cleanup() + + groupName := "test-raw-api-group" + + t.Run("create_group_raw", func(t *testing.T) { + resp, err := callIAMAPIAuthenticated(t, framework, "CreateGroup", url.Values{ + "GroupName": {groupName}, + }) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var createResp CreateGroupResponse + err = xml.Unmarshal(body, &createResp) + require.NoError(t, err) + assert.Equal(t, groupName, createResp.CreateGroupResult.Group.GroupName) + }) + + t.Run("list_groups_raw", func(t *testing.T) { + resp, err := callIAMAPIAuthenticated(t, framework, "ListGroups", url.Values{}) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var listResp ListGroupsResponse + err = xml.Unmarshal(body, &listResp) + require.NoError(t, err) + + found := false + for _, g := range listResp.ListGroupsResult.Groups { + if g.GroupName == groupName { + found = true + break + } + } + assert.True(t, found, "Created group should appear in raw ListGroups") + }) + + t.Run("delete_group_raw", func(t *testing.T) { + resp, err := callIAMAPIAuthenticated(t, framework, "DeleteGroup", url.Values{ + "GroupName": {groupName}, + }) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) +} + +// createS3Client creates an S3 client with static credentials +func createS3Client(t *testing.T, accessKey, secretKey string) *s3.S3 { + sess, err := session.NewSession(&aws.Config{ + Region: aws.String("us-east-1"), + Endpoint: aws.String(TestS3Endpoint), + Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""), + DisableSSL: aws.Bool(true), + S3ForcePathStyle: aws.Bool(true), + }) + require.NoError(t, err) + return s3.New(sess) +} diff --git a/weed/admin/dash/admin_data.go b/weed/admin/dash/admin_data.go index cb120d873..46a7ddb14 100644 --- a/weed/admin/dash/admin_data.go +++ b/weed/admin/dash/admin_data.go @@ -90,6 +90,7 @@ type UserDetails struct { Actions []string `json:"actions"` PolicyNames []string `json:"policy_names"` AccessKeys []AccessKeyInfo `json:"access_keys"` + Groups []string `json:"groups"` } type FilerNode struct { diff --git a/weed/admin/dash/group_management.go b/weed/admin/dash/group_management.go new file mode 100644 index 000000000..57b217189 --- /dev/null +++ b/weed/admin/dash/group_management.go @@ -0,0 +1,250 @@ +package dash + +import ( + "context" + "errors" + "fmt" + + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" +) + +// cloneGroup creates a deep copy of an iam_pb.Group to avoid mutating stored state. +func cloneGroup(g *iam_pb.Group) *iam_pb.Group { + clone := &iam_pb.Group{ + Name: g.Name, + Disabled: g.Disabled, + } + if g.Members != nil { + clone.Members = make([]string, len(g.Members)) + copy(clone.Members, g.Members) + } + if g.PolicyNames != nil { + clone.PolicyNames = make([]string, len(g.PolicyNames)) + copy(clone.PolicyNames, g.PolicyNames) + } + return clone +} + +func (s *AdminServer) GetGroups(ctx context.Context) ([]GroupData, error) { + if s.credentialManager == nil { + return nil, fmt.Errorf("credential manager not available") + } + + groupNames, err := s.credentialManager.ListGroups(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list groups: %w", err) + } + + var groups []GroupData + for _, name := range groupNames { + g, err := s.credentialManager.GetGroup(ctx, name) + if err != nil { + if errors.Is(err, credential.ErrGroupNotFound) { + glog.V(1).Infof("Group %s listed but not found, skipping", name) + continue + } + return nil, fmt.Errorf("failed to get group %s: %w", name, err) + } + status := "enabled" + if g.Disabled { + status = "disabled" + } + groups = append(groups, GroupData{ + Name: g.Name, + MemberCount: len(g.Members), + PolicyCount: len(g.PolicyNames), + Status: status, + Members: g.Members, + PolicyNames: g.PolicyNames, + }) + } + return groups, nil +} + +func (s *AdminServer) GetGroupDetails(ctx context.Context, name string) (*GroupData, error) { + if s.credentialManager == nil { + return nil, fmt.Errorf("credential manager not available") + } + + g, err := s.credentialManager.GetGroup(ctx, name) + if err != nil { + return nil, fmt.Errorf("failed to get group: %w", err) + } + status := "enabled" + if g.Disabled { + status = "disabled" + } + return &GroupData{ + Name: g.Name, + MemberCount: len(g.Members), + PolicyCount: len(g.PolicyNames), + Status: status, + Members: g.Members, + PolicyNames: g.PolicyNames, + }, nil +} + +func (s *AdminServer) CreateGroup(ctx context.Context, name string) (*GroupData, error) { + if s.credentialManager == nil { + return nil, fmt.Errorf("credential manager not available") + } + + group := &iam_pb.Group{Name: name} + if err := s.credentialManager.CreateGroup(ctx, group); err != nil { + return nil, fmt.Errorf("failed to create group: %w", err) + } + glog.V(1).Infof("Created group %s", group.Name) + return &GroupData{ + Name: group.Name, + Status: "enabled", + }, nil +} + +func (s *AdminServer) DeleteGroup(ctx context.Context, name string) error { + if s.credentialManager == nil { + return fmt.Errorf("credential manager not available") + } + // Check for members and attached policies before deleting (same guards as IAM handlers) + g, err := s.credentialManager.GetGroup(ctx, name) + if err != nil { + return fmt.Errorf("failed to get group: %w", err) + } + if len(g.Members) > 0 { + return fmt.Errorf("cannot delete group %s: group has %d member(s): %w", name, len(g.Members), credential.ErrGroupNotEmpty) + } + if len(g.PolicyNames) > 0 { + return fmt.Errorf("cannot delete group %s: group has %d attached policy(ies): %w", name, len(g.PolicyNames), credential.ErrGroupNotEmpty) + } + if err := s.credentialManager.DeleteGroup(ctx, name); err != nil { + return fmt.Errorf("failed to delete group: %w", err) + } + glog.V(1).Infof("Deleted group %s", name) + return nil +} + +func (s *AdminServer) AddGroupMember(ctx context.Context, groupName, username string) error { + if s.credentialManager == nil { + return fmt.Errorf("credential manager not available") + } + g, err := s.credentialManager.GetGroup(ctx, groupName) + if err != nil { + return fmt.Errorf("failed to get group: %w", err) + } + g = cloneGroup(g) + if _, err := s.credentialManager.GetUser(ctx, username); err != nil { + return fmt.Errorf("user %s not found: %w", username, err) + } + for _, m := range g.Members { + if m == username { + return nil // already a member + } + } + g.Members = append(g.Members, username) + if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { + return fmt.Errorf("failed to update group: %w", err) + } + glog.V(1).Infof("Added user %s to group %s", username, groupName) + return nil +} + +func (s *AdminServer) RemoveGroupMember(ctx context.Context, groupName, username string) error { + if s.credentialManager == nil { + return fmt.Errorf("credential manager not available") + } + g, err := s.credentialManager.GetGroup(ctx, groupName) + if err != nil { + return fmt.Errorf("failed to get group: %w", err) + } + g = cloneGroup(g) + found := false + var newMembers []string + for _, m := range g.Members { + if m == username { + found = true + } else { + newMembers = append(newMembers, m) + } + } + if !found { + return fmt.Errorf("user %s is not a member of group %s: %w", username, groupName, credential.ErrUserNotInGroup) + } + g.Members = newMembers + if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { + return fmt.Errorf("failed to update group: %w", err) + } + glog.V(1).Infof("Removed user %s from group %s", username, groupName) + return nil +} + +func (s *AdminServer) AttachGroupPolicy(ctx context.Context, groupName, policyName string) error { + if s.credentialManager == nil { + return fmt.Errorf("credential manager not available") + } + g, err := s.credentialManager.GetGroup(ctx, groupName) + if err != nil { + return fmt.Errorf("failed to get group: %w", err) + } + g = cloneGroup(g) + if _, err := s.credentialManager.GetPolicy(ctx, policyName); err != nil { + return fmt.Errorf("policy %s not found: %w", policyName, err) + } + for _, p := range g.PolicyNames { + if p == policyName { + return nil // already attached + } + } + g.PolicyNames = append(g.PolicyNames, policyName) + if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { + return fmt.Errorf("failed to update group: %w", err) + } + glog.V(1).Infof("Attached policy %s to group %s", policyName, groupName) + return nil +} + +func (s *AdminServer) DetachGroupPolicy(ctx context.Context, groupName, policyName string) error { + if s.credentialManager == nil { + return fmt.Errorf("credential manager not available") + } + g, err := s.credentialManager.GetGroup(ctx, groupName) + if err != nil { + return fmt.Errorf("failed to get group: %w", err) + } + g = cloneGroup(g) + found := false + var newPolicies []string + for _, p := range g.PolicyNames { + if p == policyName { + found = true + } else { + newPolicies = append(newPolicies, p) + } + } + if !found { + return fmt.Errorf("policy %s is not attached to group %s: %w", policyName, groupName, credential.ErrPolicyNotAttached) + } + g.PolicyNames = newPolicies + if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { + return fmt.Errorf("failed to update group: %w", err) + } + glog.V(1).Infof("Detached policy %s from group %s", policyName, groupName) + return nil +} + +func (s *AdminServer) SetGroupStatus(ctx context.Context, groupName string, enabled bool) error { + if s.credentialManager == nil { + return fmt.Errorf("credential manager not available") + } + g, err := s.credentialManager.GetGroup(ctx, groupName) + if err != nil { + return fmt.Errorf("failed to get group: %w", err) + } + g = cloneGroup(g) + g.Disabled = !enabled + if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { + return fmt.Errorf("failed to update group: %w", err) + } + glog.V(1).Infof("Set group %s status to enabled=%v", groupName, enabled) + return nil +} diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go index 4dbdc965c..965166de4 100644 --- a/weed/admin/dash/types.go +++ b/weed/admin/dash/types.go @@ -589,6 +589,30 @@ type UpdateServiceAccountRequest struct { Expiration string `json:"expiration,omitempty"` } +// Group management structures +type GroupData struct { + Name string `json:"name"` + MemberCount int `json:"member_count"` + PolicyCount int `json:"policy_count"` + Status string `json:"status"` // "enabled" or "disabled" + Members []string `json:"members"` + PolicyNames []string `json:"policy_names"` +} + +type GroupsPageData struct { + Username string `json:"username"` + Groups []GroupData `json:"groups"` + TotalGroups int `json:"total_groups"` + ActiveGroups int `json:"active_groups"` + AvailableUsers []string `json:"available_users"` + AvailablePolicies []string `json:"available_policies"` + LastUpdated time.Time `json:"last_updated"` +} + +type CreateGroupRequest struct { + Name string `json:"name"` +} + // STS Configuration display types type STSConfigData struct { Enabled bool `json:"enabled"` diff --git a/weed/admin/dash/user_management.go b/weed/admin/dash/user_management.go index 3f2d48feb..ecae2169b 100644 --- a/weed/admin/dash/user_management.go +++ b/weed/admin/dash/user_management.go @@ -187,6 +187,24 @@ func (s *AdminServer) GetObjectStoreUserDetails(username string) (*UserDetails, details.Email = identity.Account.EmailAddress } + // Look up groups the user belongs to + groupNames, err := s.credentialManager.ListGroups(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list groups: %w", err) + } + for _, gName := range groupNames { + g, err := s.credentialManager.GetGroup(ctx, gName) + if err != nil { + return nil, fmt.Errorf("failed to get group %s: %w", gName, err) + } + for _, member := range g.Members { + if member == username { + details.Groups = append(details.Groups, gName) + break + } + } + } + // Convert credentials to access key info for _, cred := range identity.Credentials { details.AccessKeys = append(details.AccessKeys, AccessKeyInfo{ diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index ff0d8651a..38938c25b 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -28,6 +28,7 @@ type AdminHandlers struct { pluginHandlers *PluginHandlers mqHandlers *MessageQueueHandlers serviceAccountHandlers *ServiceAccountHandlers + groupHandlers *GroupHandlers } // NewAdminHandlers creates a new instance of AdminHandlers @@ -40,6 +41,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *Admi pluginHandlers := NewPluginHandlers(adminServer) mqHandlers := NewMessageQueueHandlers(adminServer) serviceAccountHandlers := NewServiceAccountHandlers(adminServer) + groupHandlers := NewGroupHandlers(adminServer) return &AdminHandlers{ adminServer: adminServer, sessionStore: store, @@ -51,6 +53,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *Admi pluginHandlers: pluginHandlers, mqHandlers: mqHandlers, serviceAccountHandlers: serviceAccountHandlers, + groupHandlers: groupHandlers, } } @@ -104,6 +107,7 @@ func (h *AdminHandlers) registerUIRoutes(r *mux.Router) { r.HandleFunc("/object-store/buckets/{bucket}", h.ShowBucketDetails).Methods(http.MethodGet) r.HandleFunc("/object-store/users", h.userHandlers.ShowObjectStoreUsers).Methods(http.MethodGet) r.HandleFunc("/object-store/policies", h.policyHandlers.ShowPolicies).Methods(http.MethodGet) + r.HandleFunc("/object-store/groups", h.groupHandlers.ShowGroups).Methods(http.MethodGet) r.HandleFunc("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts).Methods(http.MethodGet) r.HandleFunc("/object-store/s3tables/buckets", h.ShowS3TablesBuckets).Methods(http.MethodGet) r.HandleFunc("/object-store/s3tables/buckets/{bucket}/namespaces", h.ShowS3TablesNamespaces).Methods(http.MethodGet) @@ -185,6 +189,19 @@ func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) { saApi.Handle("/{id}", wrapWrite(h.serviceAccountHandlers.UpdateServiceAccount)).Methods(http.MethodPut) saApi.Handle("/{id}", wrapWrite(h.serviceAccountHandlers.DeleteServiceAccount)).Methods(http.MethodDelete) + groupsApi := api.PathPrefix("/groups").Subrouter() + groupsApi.HandleFunc("", h.groupHandlers.GetGroups).Methods(http.MethodGet) + groupsApi.Handle("", wrapWrite(h.groupHandlers.CreateGroup)).Methods(http.MethodPost) + groupsApi.HandleFunc("/{name}", h.groupHandlers.GetGroupDetails).Methods(http.MethodGet) + groupsApi.Handle("/{name}", wrapWrite(h.groupHandlers.DeleteGroup)).Methods(http.MethodDelete) + groupsApi.Handle("/{name}/status", wrapWrite(h.groupHandlers.SetGroupStatus)).Methods(http.MethodPut) + groupsApi.HandleFunc("/{name}/members", h.groupHandlers.GetGroupMembers).Methods(http.MethodGet) + groupsApi.Handle("/{name}/members", wrapWrite(h.groupHandlers.AddGroupMember)).Methods(http.MethodPost) + groupsApi.Handle("/{name}/members/{username}", wrapWrite(h.groupHandlers.RemoveGroupMember)).Methods(http.MethodDelete) + groupsApi.HandleFunc("/{name}/policies", h.groupHandlers.GetGroupPolicies).Methods(http.MethodGet) + groupsApi.Handle("/{name}/policies", wrapWrite(h.groupHandlers.AttachGroupPolicy)).Methods(http.MethodPost) + groupsApi.Handle("/{name}/policies/{policyName}", wrapWrite(h.groupHandlers.DetachGroupPolicy)).Methods(http.MethodDelete) + policyApi := api.PathPrefix("/object-store/policies").Subrouter() policyApi.HandleFunc("", h.policyHandlers.GetPolicies).Methods(http.MethodGet) policyApi.Handle("", wrapWrite(h.policyHandlers.CreatePolicy)).Methods(http.MethodPost) diff --git a/weed/admin/handlers/group_handlers.go b/weed/admin/handlers/group_handlers.go new file mode 100644 index 000000000..57fc5d8c6 --- /dev/null +++ b/weed/admin/handlers/group_handlers.go @@ -0,0 +1,271 @@ +package handlers + +import ( + "bytes" + "errors" + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" + "github.com/seaweedfs/seaweedfs/weed/admin/view/app" + "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/glog" +) + +func groupErrorToHTTPStatus(err error) int { + if errors.Is(err, credential.ErrGroupNotFound) { + return http.StatusNotFound + } + if errors.Is(err, credential.ErrGroupAlreadyExists) { + return http.StatusConflict + } + if errors.Is(err, credential.ErrUserNotInGroup) { + return http.StatusBadRequest + } + if errors.Is(err, credential.ErrPolicyNotAttached) { + return http.StatusBadRequest + } + if errors.Is(err, credential.ErrUserNotFound) { + return http.StatusNotFound + } + if errors.Is(err, credential.ErrPolicyNotFound) { + return http.StatusNotFound + } + if errors.Is(err, credential.ErrGroupNotEmpty) { + return http.StatusConflict + } + return http.StatusInternalServerError +} + +type GroupHandlers struct { + adminServer *dash.AdminServer +} + +func NewGroupHandlers(adminServer *dash.AdminServer) *GroupHandlers { + return &GroupHandlers{adminServer: adminServer} +} + +func (h *GroupHandlers) ShowGroups(w http.ResponseWriter, r *http.Request) { + data, err := h.getGroupsPageData(r) + if err != nil { + glog.Errorf("Failed to get groups data: %v", err) + writeJSONError(w, http.StatusInternalServerError, "Failed to load groups: "+err.Error()) + return + } + + var buf bytes.Buffer + component := app.Groups(data) + viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context())) + layoutComponent := layout.Layout(viewCtx, component) + if err := layoutComponent.Render(r.Context(), &buf); err != nil { + glog.Errorf("Failed to render groups template: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write(buf.Bytes()) +} + +func (h *GroupHandlers) GetGroups(w http.ResponseWriter, r *http.Request) { + groups, err := h.adminServer.GetGroups(r.Context()) + if err != nil { + glog.Errorf("Failed to get groups: %v", err) + writeJSONError(w, http.StatusInternalServerError, "Failed to get groups") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"groups": groups}) +} + +func (h *GroupHandlers) CreateGroup(w http.ResponseWriter, r *http.Request) { + var req dash.CreateGroupRequest + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + if req.Name == "" { + writeJSONError(w, http.StatusBadRequest, "Group name is required") + return + } + group, err := h.adminServer.CreateGroup(r.Context(), req.Name) + if err != nil { + glog.Errorf("Failed to create group: %v", err) + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to create group: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, group) +} + +func (h *GroupHandlers) GetGroupDetails(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + group, err := h.adminServer.GetGroupDetails(r.Context(), name) + if err != nil { + glog.Errorf("Failed to get group details: %v", err) + status := groupErrorToHTTPStatus(err) + msg := "Failed to retrieve group" + if status == http.StatusNotFound { + msg = "Group not found" + } + writeJSONError(w, status, msg) + return + } + writeJSON(w, http.StatusOK, group) +} + +func (h *GroupHandlers) DeleteGroup(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + if err := h.adminServer.DeleteGroup(r.Context(), name); err != nil { + glog.Errorf("Failed to delete group: %v", err) + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to delete group: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Group deleted successfully"}) +} + +func (h *GroupHandlers) GetGroupMembers(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + group, err := h.adminServer.GetGroupDetails(r.Context(), name) + if err != nil { + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to get group: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"members": group.Members}) +} + +func (h *GroupHandlers) AddGroupMember(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + var req struct { + Username string `json:"username"` + } + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + if req.Username == "" { + writeJSONError(w, http.StatusBadRequest, "Username is required") + return + } + if err := h.adminServer.AddGroupMember(r.Context(), name, req.Username); err != nil { + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to add member: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Member added successfully"}) +} + +func (h *GroupHandlers) RemoveGroupMember(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + username := mux.Vars(r)["username"] + if err := h.adminServer.RemoveGroupMember(r.Context(), name, username); err != nil { + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to remove member: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Member removed successfully"}) +} + +func (h *GroupHandlers) GetGroupPolicies(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + group, err := h.adminServer.GetGroupDetails(r.Context(), name) + if err != nil { + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to get group: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"policies": group.PolicyNames}) +} + +func (h *GroupHandlers) AttachGroupPolicy(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + var req struct { + PolicyName string `json:"policy_name"` + } + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + if req.PolicyName == "" { + writeJSONError(w, http.StatusBadRequest, "Policy name is required") + return + } + if err := h.adminServer.AttachGroupPolicy(r.Context(), name, req.PolicyName); err != nil { + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to attach policy: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Policy attached successfully"}) +} + +func (h *GroupHandlers) DetachGroupPolicy(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + policyName := mux.Vars(r)["policyName"] + if err := h.adminServer.DetachGroupPolicy(r.Context(), name, policyName); err != nil { + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to detach policy: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Policy detached successfully"}) +} + +func (h *GroupHandlers) SetGroupStatus(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + var req struct { + Enabled *bool `json:"enabled"` + } + if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { + writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + if req.Enabled == nil { + writeJSONError(w, http.StatusBadRequest, "enabled field is required") + return + } + if err := h.adminServer.SetGroupStatus(r.Context(), name, *req.Enabled); err != nil { + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to update group status: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Group status updated"}) +} + +func (h *GroupHandlers) getGroupsPageData(r *http.Request) (dash.GroupsPageData, error) { + username := dash.UsernameFromContext(r.Context()) + if username == "" { + username = "admin" + } + + groups, err := h.adminServer.GetGroups(r.Context()) + if err != nil { + return dash.GroupsPageData{}, err + } + + activeCount := 0 + for _, g := range groups { + if g.Status == "enabled" { + activeCount++ + } + } + + // Get available users for dropdown + var availableUsers []string + users, err := h.adminServer.GetObjectStoreUsers(r.Context()) + if err == nil { + for _, user := range users { + availableUsers = append(availableUsers, user.Username) + } + } + + // Get available policies for dropdown + var availablePolicies []string + policies, err := h.adminServer.GetPolicies() + if err == nil { + for _, p := range policies { + availablePolicies = append(availablePolicies, p.Name) + } + } + + return dash.GroupsPageData{ + Username: username, + Groups: groups, + TotalGroups: len(groups), + ActiveGroups: activeCount, + AvailableUsers: availableUsers, + AvailablePolicies: availablePolicies, + LastUpdated: time.Now(), + }, nil +} diff --git a/weed/admin/static/js/admin.js b/weed/admin/static/js/admin.js index 7891645c7..316f9d2a0 100644 --- a/weed/admin/static/js/admin.js +++ b/weed/admin/static/js/admin.js @@ -478,7 +478,7 @@ async function handleCreateBucket(event) { if (response.ok) { // Success - showAlert('success', `Bucket "${bucketData.name}" created successfully!`); + showAlert(`Bucket "${bucketData.name}" created successfully!`, 'success'); // Close modal const modal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal')); @@ -493,11 +493,11 @@ async function handleCreateBucket(event) { }, 1500); } else { // Error - showAlert('danger', result.error || 'Failed to create bucket'); + showAlert(result.error || 'Failed to create bucket', 'danger'); } } catch (error) { console.error('Error creating bucket:', error); - showAlert('danger', 'Network error occurred while creating bucket'); + showAlert('Network error occurred while creating bucket', 'danger'); } } @@ -538,7 +538,7 @@ async function deleteBucket() { if (response.ok) { // Success - showAlert('success', `Bucket "${bucketToDelete}" deleted successfully!`); + showAlert(`Bucket "${bucketToDelete}" deleted successfully!`, 'success'); // Close modal const modal = bootstrap.Modal.getInstance(document.getElementById('deleteBucketModal')); @@ -550,11 +550,11 @@ async function deleteBucket() { }, 1500); } else { // Error - showAlert('danger', result.error || 'Failed to delete bucket'); + showAlert(result.error || 'Failed to delete bucket', 'danger'); } } catch (error) { console.error('Error deleting bucket:', error); - showAlert('danger', 'Network error occurred while deleting bucket'); + showAlert('Network error occurred while deleting bucket', 'danger'); } bucketToDelete = ''; @@ -609,38 +609,7 @@ function exportBucketList() { window.URL.revokeObjectURL(url); } -// Show alert message -function showAlert(type, message) { - // Remove existing alerts - const existingAlerts = document.querySelectorAll('.alert-floating'); - existingAlerts.forEach(alert => alert.remove()); - - // Create new alert - const alert = document.createElement('div'); - alert.className = `alert alert-${type} alert-dismissible fade show alert-floating`; - alert.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - z-index: 9999; - min-width: 300px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - `; - - alert.innerHTML = ` - ${message} - - `; - - document.body.appendChild(alert); - - // Auto-remove after 5 seconds - setTimeout(() => { - if (alert.parentNode) { - alert.remove(); - } - }, 5000); -} +// showAlert is provided by modal-alerts.js with signature: showAlert(message, type) // Format date for display function formatDate(date) { @@ -651,7 +620,7 @@ function formatDate(date) { function adminCopyToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => { - showAlert('success', 'Copied to clipboard!'); + showAlert('Copied to clipboard!', 'success'); }).catch(err => { console.error('Failed to copy text: ', err); fallbackCopyText(text); @@ -677,13 +646,13 @@ function fallbackCopyText(text) { try { const successful = document.execCommand('copy'); if (successful) { - showAlert('success', 'Copied to clipboard!'); + showAlert('Copied to clipboard!', 'success'); } else { - showAlert('danger', 'Failed to copy to clipboard'); + showAlert('Failed to copy to clipboard', 'danger'); } } catch (err) { console.error('Fallback copy failed: ', err); - showAlert('danger', 'Failed to copy to clipboard'); + showAlert('Failed to copy to clipboard', 'danger'); } document.body.removeChild(textArea); @@ -764,7 +733,7 @@ function exportVolumes() { function exportCollections() { const table = document.getElementById('collectionsTable'); if (!table) { - showAlert('error', 'Collections table not found'); + showAlert('Collections table not found', 'error'); return; } @@ -800,7 +769,7 @@ function exportCollections() { function exportMasters() { const table = document.getElementById('mastersTable'); if (!table) { - showAlert('error', 'Masters table not found'); + showAlert('Masters table not found', 'error'); return; } @@ -834,7 +803,7 @@ function exportMasters() { function exportFilers() { const table = document.getElementById('filersTable'); if (!table) { - showAlert('error', 'Filers table not found'); + showAlert('Filers table not found', 'error'); return; } @@ -870,7 +839,7 @@ function exportFilers() { function exportUsers() { const table = document.getElementById('usersTable'); if (!table) { - showAlert('error', 'Users table not found'); + showAlert('Users table not found', 'error'); return; } @@ -1020,7 +989,7 @@ function confirmDeleteSelected() { const selectedPaths = getSelectedFilePaths(); if (selectedPaths.length === 0) { - showAlert('warning', 'No files selected'); + showAlert('No files selected', 'warning'); return; } @@ -1041,7 +1010,7 @@ function confirmDeleteSelected() { // Delete multiple selected files async function deleteSelectedFiles(filePaths) { if (!filePaths || filePaths.length === 0) { - showAlert('warning', 'No files selected'); + showAlert('No files selected', 'warning'); return; } @@ -1065,9 +1034,9 @@ async function deleteSelectedFiles(filePaths) { if (result.deleted > 0) { if (result.failed === 0) { - showAlert('success', `Successfully deleted ${result.deleted} item(s)`); + showAlert(`Successfully deleted ${result.deleted} item(s)`, 'success'); } else { - showAlert('warning', `Deleted ${result.deleted} item(s), failed to delete ${result.failed} item(s)`); + showAlert(`Deleted ${result.deleted} item(s), failed to delete ${result.failed} item(s)`, 'warning'); if (result.errors && result.errors.length > 0) { console.warn('Deletion errors:', result.errors); } @@ -1082,15 +1051,15 @@ async function deleteSelectedFiles(filePaths) { if (result.errors && result.errors.length > 0) { errorMessage += ': ' + result.errors.join(', '); } - showAlert('error', errorMessage); + showAlert(errorMessage, 'error'); } } else { const error = await response.json(); - showAlert('error', `Failed to delete files: ${error.error || 'Unknown error'}`); + showAlert(`Failed to delete files: ${error.error || 'Unknown error'}`, 'error'); } } catch (error) { console.error('Delete error:', error); - showAlert('error', 'Failed to delete files'); + showAlert('Failed to delete files', 'error'); } finally { // Re-enable the button deleteBtn.disabled = false; @@ -1311,7 +1280,7 @@ async function submitUploadFile() { function exportFileList() { const table = document.getElementById('fileTable'); if (!table) { - showAlert('error', 'File table not found'); + showAlert('File table not found', 'error'); return; } @@ -1357,7 +1326,7 @@ async function viewFile(filePath) { if (!response.ok) { const error = await response.json(); - showAlert('error', `Failed to view file: ${error.error || 'Unknown error'}`); + showAlert(`Failed to view file: ${error.error || 'Unknown error'}`, 'error'); return; } @@ -1366,7 +1335,7 @@ async function viewFile(filePath) { } catch (error) { console.error('View file error:', error); - showAlert('error', 'Failed to view file'); + showAlert('Failed to view file', 'error'); } } @@ -1377,7 +1346,7 @@ async function showProperties(filePath) { if (!response.ok) { const error = await response.json(); - showAlert('error', `Failed to get file properties: ${error.error || 'Unknown error'}`); + showAlert(`Failed to get file properties: ${error.error || 'Unknown error'}`, 'error'); return; } @@ -1386,7 +1355,7 @@ async function showProperties(filePath) { } catch (error) { console.error('Properties error:', error); - showAlert('error', 'Failed to get file properties'); + showAlert('Failed to get file properties', 'error'); } } @@ -1413,16 +1382,16 @@ async function deleteFile(filePath) { }); if (response.ok) { - showAlert('success', `Successfully deleted "${filePath}"`); + showAlert(`Successfully deleted "${filePath}"`, 'success'); // Reload the page to update the file list window.location.reload(); } else { const error = await response.json(); - showAlert('error', `Failed to delete file: ${error.error || 'Unknown error'}`); + showAlert(`Failed to delete file: ${error.error || 'Unknown error'}`, 'error'); } } catch (error) { console.error('Delete error:', error); - showAlert('error', 'Failed to delete file'); + showAlert('Failed to delete file', 'error'); } } @@ -1737,7 +1706,7 @@ async function handleUpdateQuota(event) { if (response.ok) { // Success - showAlert('success', `Quota for bucket "${bucketName}" updated successfully!`); + showAlert(`Quota for bucket "${bucketName}" updated successfully!`, 'success'); // Close modal const modal = bootstrap.Modal.getInstance(document.getElementById('manageQuotaModal')); @@ -1749,11 +1718,11 @@ async function handleUpdateQuota(event) { }, 1500); } else { // Error - showAlert('danger', result.error || 'Failed to update bucket quota'); + showAlert(result.error || 'Failed to update bucket quota', 'danger'); } } catch (error) { console.error('Error updating bucket quota:', error); - showAlert('danger', 'Network error occurred while updating bucket quota'); + showAlert('Network error occurred while updating bucket quota', 'danger'); } } @@ -2274,21 +2243,21 @@ function copyFromInput(inputId) { try { const successful = document.execCommand('copy'); if (successful) { - showAlert('success', 'Copied to clipboard!'); + showAlert('Copied to clipboard!', 'success'); } else { // Try modern clipboard API as fallback navigator.clipboard.writeText(input.value).then(() => { - showAlert('success', 'Copied to clipboard!'); + showAlert('Copied to clipboard!', 'success'); }).catch(() => { - showAlert('danger', 'Failed to copy'); + showAlert('Failed to copy', 'danger'); }); } } catch (err) { // Try modern clipboard API as fallback navigator.clipboard.writeText(input.value).then(() => { - showAlert('success', 'Copied to clipboard!'); + showAlert('Copied to clipboard!', 'success'); }).catch(() => { - showAlert('danger', 'Failed to copy'); + showAlert('Failed to copy', 'danger'); }); } } diff --git a/weed/admin/static/js/iam-utils.js b/weed/admin/static/js/iam-utils.js index baf8ba457..1b50d54a6 100644 --- a/weed/admin/static/js/iam-utils.js +++ b/weed/admin/static/js/iam-utils.js @@ -25,6 +25,29 @@ async function deleteUser(username) { }, 'Are you sure you want to delete this user? This action cannot be undone.'); } +// Delete group function +async function deleteGroup(name) { + showDeleteConfirm(name, async function () { + try { + const encodedName = encodeURIComponent(name); + const response = await fetch(`/api/groups/${encodedName}`, { + method: 'DELETE' + }); + + if (response.ok) { + showAlert('Group deleted successfully', 'success'); + setTimeout(() => window.location.reload(), 1000); + } else { + const error = await response.json().catch(() => ({})); + showAlert('Failed to delete group: ' + (error.error || 'Unknown error'), 'error'); + } + } catch (error) { + console.error('Error deleting group:', error); + showAlert('Failed to delete group: ' + error.message, 'error'); + } + }, 'Are you sure you want to delete this group? This action cannot be undone.'); +} + // Delete access key function async function deleteAccessKey(username, accessKey) { showDeleteConfirm(accessKey, async function () { diff --git a/weed/admin/view/app/groups.templ b/weed/admin/view/app/groups.templ new file mode 100644 index 000000000..fe3c1b8d9 --- /dev/null +++ b/weed/admin/view/app/groups.templ @@ -0,0 +1,443 @@ +package app + +import ( + "fmt" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" +) + +templ Groups(data dash.GroupsPageData) { +
+ +
+
+

+ Groups +

+

Manage IAM groups for organizing users and policies

+
+
+ +
+
+ + +
+
+
+
+
+
+
+ Total Groups +
+
+ {fmt.Sprintf("%d", data.TotalGroups)} +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ Active Groups +
+
+ {fmt.Sprintf("%d", data.ActiveGroups)} +
+
+
+ +
+
+
+
+
+
+ + +
+
+
Groups
+
+
+ if len(data.Groups) == 0 { +
+ +

No groups found. Create a group to get started.

+
+ } else { +
+ + + + + + + + + + + + for _, group := range data.Groups { + + + + + + + + } + +
NameMembersPoliciesStatusActions
+ {group.Name} + + {fmt.Sprintf("%d", group.MemberCount)} + + {fmt.Sprintf("%d", group.PolicyCount)} + + if group.Status == "enabled" { + Enabled + } else { + Disabled + } + + + +
+
+ } +
+
+ + + + + + +
+ + + +} diff --git a/weed/admin/view/app/groups_templ.go b/weed/admin/view/app/groups_templ.go new file mode 100644 index 000000000..ed651fbe3 --- /dev/null +++ b/weed/admin/view/app/groups_templ.go @@ -0,0 +1,300 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package app + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" +) + +func Groups(data dash.GroupsPageData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Groups

Manage IAM groups for organizing users and policies

Total Groups
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalGroups)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/groups.templ`, Line: 38, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Active Groups
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.ActiveGroups)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/groups.templ`, Line: 57, Col: 73} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
Groups
") + 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, "
") + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
NameMembersPoliciesStatusActions
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(group.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/groups.templ`, Line: 96, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", group.MemberCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/groups.templ`, Line: 99, Col: 109} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", group.PolicyCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/groups.templ`, Line: 102, Col: 114} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if group.Status == "enabled" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Enabled") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Disabled") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Create Group
Group Details
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/admin/view/app/object_store_users.templ b/weed/admin/view/app/object_store_users.templ index 9a864bad5..86715dc54 100644 --- a/weed/admin/view/app/object_store_users.templ +++ b/weed/admin/view/app/object_store_users.templ @@ -384,6 +384,21 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) { +
+ +
+ +
+
+ + +
+
'; + detailsHtml += '
Groups
'; + detailsHtml += '
'; + if (user.groups && user.groups.length > 0) { + detailsHtml += user.groups.map(function(group) { + return '' + escapeHtml(group) + ''; + }).join(''); + } else { + detailsHtml += 'No groups'; + } + detailsHtml += '
'; detailsHtml += '
Access Keys
'; if (user.access_keys && user.access_keys.length > 0) { detailsHtml += '
'; diff --git a/weed/admin/view/app/object_store_users_templ.go b/weed/admin/view/app/object_store_users_templ.go index 34b8cf517..7f2a73c34 100644 --- a/weed/admin/view/app/object_store_users_templ.go +++ b/weed/admin/view/app/object_store_users_templ.go @@ -193,7 +193,7 @@ func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
Create New User
Hold Ctrl/Cmd to select multiple permissions
Apply selected permissions to specific buckets or all buckets
Hold Ctrl/Cmd to select multiple buckets
Hold Ctrl/Cmd to select multiple policies
Edit User
Apply selected permissions to specific buckets or all buckets
Hold Ctrl/Cmd to select multiple buckets
User Details
Manage Access Keys
Access Keys for
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
Create New User
Hold Ctrl/Cmd to select multiple permissions
Apply selected permissions to specific buckets or all buckets
Hold Ctrl/Cmd to select multiple buckets
Hold Ctrl/Cmd to select multiple policies
Edit User
Apply selected permissions to specific buckets or all buckets
Hold Ctrl/Cmd to select multiple buckets
User Details
Manage Access Keys
Access Keys for
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ index 6e87b462a..d717548fa 100644 --- a/weed/admin/view/layout/layout.templ +++ b/weed/admin/view/layout/layout.templ @@ -168,6 +168,11 @@ templ Layout(view ViewContext, content templ.Component) { Users +
OBJECT STORE
MANAGEMENT
OBJECT STORE
MANAGEMENT