Browse Source
Merge
Merge db5751f370 into 78a3441b30
committed by
GitHub
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 4107 additions and 249 deletions
-
13.github/workflows/s3-iam-tests.yml
-
5test/s3/iam/Makefile
-
780test/s3/iam/s3_iam_group_test.go
-
246weed/admin/dash/group_management.go
-
24weed/admin/dash/types.go
-
17weed/admin/handlers/admin_handlers.go
-
271weed/admin/handlers/group_handlers.go
-
23weed/admin/static/js/iam-utils.js
-
439weed/admin/view/app/groups.templ
-
248weed/admin/view/app/groups_templ.go
-
5weed/admin/view/layout/layout.templ
-
30weed/admin/view/layout/layout_templ.go
-
22weed/credential/credential_manager.go
-
11weed/credential/credential_store.go
-
178weed/credential/filer_etc/filer_etc_group.go
-
55weed/credential/filer_etc/filer_etc_identity.go
-
87weed/credential/grpc/grpc_group.go
-
89weed/credential/memory/memory_group.go
-
4weed/credential/memory/memory_store.go
-
127weed/credential/postgres/postgres_group.go
-
28weed/credential/postgres/postgres_store.go
-
31weed/credential/propagating_store.go
-
90weed/iam/responses.go
-
329weed/iamapi/iamapi_group_handlers.go
-
174weed/iamapi/iamapi_management_handlers.go
-
12weed/iamapi/iamapi_response.go
-
8weed/pb/iam.proto
-
454weed/pb/iam_pb/iam.pb.go
-
102weed/s3api/auth_credentials.go
-
23weed/s3api/auth_credentials_subscribe.go
-
431weed/s3api/s3api_embedded_iam.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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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(), |
|||
} |
|||
} |
|||
@ -0,0 +1,439 @@ |
|||
package app |
|||
|
|||
import ( |
|||
"fmt" |
|||
"github.com/seaweedfs/seaweedfs/weed/admin/dash" |
|||
) |
|||
|
|||
templ Groups(data dash.GroupsPageData) { |
|||
<div class="container-fluid"> |
|||
<!-- Page Header --> |
|||
<div class="d-sm-flex align-items-center justify-content-between mb-4"> |
|||
<div> |
|||
<h1 class="h3 mb-0 text-gray-800"> |
|||
<i class="fas fa-users-cog me-2"></i>Groups |
|||
</h1> |
|||
<p class="mb-0 text-muted">Manage IAM groups for organizing users and policies</p> |
|||
</div> |
|||
<div class="d-flex gap-2"> |
|||
<button type="button" class="btn btn-primary" |
|||
data-bs-toggle="modal" |
|||
data-bs-target="#createGroupModal"> |
|||
<i class="fas fa-plus me-1"></i>Create Group |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Summary Cards --> |
|||
<div class="row mb-4"> |
|||
<div class="col-xl-3 col-md-6 mb-4"> |
|||
<div class="card border-left-primary shadow h-100 py-2"> |
|||
<div class="card-body"> |
|||
<div class="row no-gutters align-items-center"> |
|||
<div class="col mr-2"> |
|||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1"> |
|||
Total Groups |
|||
</div> |
|||
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
|||
{fmt.Sprintf("%d", data.TotalGroups)} |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-users-cog fa-2x text-gray-300"></i> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="col-xl-3 col-md-6 mb-4"> |
|||
<div class="card border-left-success shadow h-100 py-2"> |
|||
<div class="card-body"> |
|||
<div class="row no-gutters align-items-center"> |
|||
<div class="col mr-2"> |
|||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1"> |
|||
Active Groups |
|||
</div> |
|||
<div class="h5 mb-0 font-weight-bold text-gray-800"> |
|||
{fmt.Sprintf("%d", data.ActiveGroups)} |
|||
</div> |
|||
</div> |
|||
<div class="col-auto"> |
|||
<i class="fas fa-check-circle fa-2x text-gray-300"></i> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Groups Table --> |
|||
<div class="card shadow mb-4"> |
|||
<div class="card-header py-3"> |
|||
<h6 class="m-0 font-weight-bold text-primary">Groups</h6> |
|||
</div> |
|||
<div class="card-body"> |
|||
if len(data.Groups) == 0 { |
|||
<div class="text-center py-5 text-muted"> |
|||
<i class="fas fa-users-cog fa-3x mb-3"></i> |
|||
<p>No groups found. Create a group to get started.</p> |
|||
</div> |
|||
} else { |
|||
<div class="table-responsive"> |
|||
<table class="table table-bordered table-hover" id="groupsTable" width="100%" cellspacing="0"> |
|||
<thead> |
|||
<tr> |
|||
<th>Name</th> |
|||
<th>Members</th> |
|||
<th>Policies</th> |
|||
<th>Status</th> |
|||
<th>Actions</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
for _, group := range data.Groups { |
|||
<tr> |
|||
<td> |
|||
<strong>{group.Name}</strong> |
|||
</td> |
|||
<td> |
|||
<span class="badge bg-info">{fmt.Sprintf("%d", group.MemberCount)}</span> |
|||
</td> |
|||
<td> |
|||
<span class="badge bg-secondary">{fmt.Sprintf("%d", group.PolicyCount)}</span> |
|||
</td> |
|||
<td> |
|||
if group.Status == "enabled" { |
|||
<span class="badge bg-success">Enabled</span> |
|||
} else { |
|||
<span class="badge bg-danger">Disabled</span> |
|||
} |
|||
</td> |
|||
<td> |
|||
<button class="btn btn-sm btn-outline-primary me-1" |
|||
data-group-name={group.Name} |
|||
data-action="view"> |
|||
<i class="fas fa-eye"></i> |
|||
</button> |
|||
<button class="btn btn-sm btn-outline-danger" |
|||
data-group-name={group.Name} |
|||
data-action="delete"> |
|||
<i class="fas fa-trash"></i> |
|||
</button> |
|||
</td> |
|||
</tr> |
|||
} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
} |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Create Group Modal --> |
|||
<div class="modal fade" id="createGroupModal" tabindex="-1"> |
|||
<div class="modal-dialog"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title">Create Group</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<form id="createGroupForm"> |
|||
<div class="mb-3"> |
|||
<label for="groupName" class="form-label">Group Name</label> |
|||
<input type="text" class="form-control" id="groupName" name="name" required |
|||
placeholder="Enter group name"/> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> |
|||
<button type="button" class="btn btn-primary" onclick="createGroup()">Create</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- View Group Modal --> |
|||
<div class="modal fade" id="viewGroupModal" tabindex="-1"> |
|||
<div class="modal-dialog modal-lg"> |
|||
<div class="modal-content"> |
|||
<div class="modal-header"> |
|||
<h5 class="modal-title" id="viewGroupTitle">Group Details</h5> |
|||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> |
|||
</div> |
|||
<div class="modal-body"> |
|||
<ul class="nav nav-tabs" id="groupTabs" role="tablist"> |
|||
<li class="nav-item"> |
|||
<a class="nav-link active" id="members-tab" data-bs-toggle="tab" href="#membersPane" role="tab">Members</a> |
|||
</li> |
|||
<li class="nav-item"> |
|||
<a class="nav-link" id="policies-tab" data-bs-toggle="tab" href="#policiesPane" role="tab">Policies</a> |
|||
</li> |
|||
<li class="nav-item"> |
|||
<a class="nav-link" id="settings-tab" data-bs-toggle="tab" href="#settingsPane" role="tab">Settings</a> |
|||
</li> |
|||
</ul> |
|||
<div class="tab-content mt-3" id="groupTabContent"> |
|||
<!-- Members Tab --> |
|||
<div class="tab-pane fade show active" id="membersPane" role="tabpanel"> |
|||
<div class="mb-3"> |
|||
<div class="input-group"> |
|||
<select class="form-select" id="addMemberSelect"> |
|||
<option value="">Select user to add...</option> |
|||
for _, user := range data.AvailableUsers { |
|||
<option value={user}>{user}</option> |
|||
} |
|||
</select> |
|||
<button class="btn btn-outline-primary" type="button" onclick="addMemberToGroup()"> |
|||
<i class="fas fa-plus"></i> Add |
|||
</button> |
|||
</div> |
|||
</div> |
|||
<div id="membersList"></div> |
|||
</div> |
|||
<!-- Policies Tab --> |
|||
<div class="tab-pane fade" id="policiesPane" role="tabpanel"> |
|||
<div class="mb-3"> |
|||
<div class="input-group"> |
|||
<select class="form-select" id="attachPolicySelect"> |
|||
<option value="">Select policy to attach...</option> |
|||
for _, policy := range data.AvailablePolicies { |
|||
<option value={policy}>{policy}</option> |
|||
} |
|||
</select> |
|||
<button class="btn btn-outline-primary" type="button" onclick="attachPolicyToGroup()"> |
|||
<i class="fas fa-plus"></i> Attach |
|||
</button> |
|||
</div> |
|||
</div> |
|||
<div id="policiesList"></div> |
|||
</div> |
|||
<!-- Settings Tab --> |
|||
<div class="tab-pane fade" id="settingsPane" role="tabpanel"> |
|||
<div class="form-check form-switch mb-3"> |
|||
<input class="form-check-input" type="checkbox" id="groupEnabledSwitch" checked |
|||
onchange="toggleGroupStatus()"/> |
|||
<label class="form-check-label" for="groupEnabledSwitch">Group Enabled</label> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<script src="/static/js/iam-utils.js"></script> |
|||
<script> |
|||
// Groups page JavaScript |
|||
let currentGroupName = ''; |
|||
|
|||
async function createGroup() { |
|||
const name = document.getElementById('groupName').value.trim(); |
|||
if (!name) { |
|||
showAlert('Group name is required', 'error'); |
|||
return; |
|||
} |
|||
try { |
|||
const response = await fetch('/api/groups', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ name: name }) |
|||
}); |
|||
if (response.ok) { |
|||
showAlert('Group created successfully', 'success'); |
|||
setTimeout(() => window.location.reload(), 1000); |
|||
} else { |
|||
const error = await response.json().catch(() => ({})); |
|||
showAlert('Failed to create group: ' + (error.error || 'Unknown error'), 'error'); |
|||
} |
|||
} catch (error) { |
|||
showAlert('Failed to create group: ' + error.message, 'error'); |
|||
} |
|||
} |
|||
|
|||
async function viewGroup(name) { |
|||
currentGroupName = name; |
|||
document.getElementById('viewGroupTitle').textContent = 'Group: ' + name; |
|||
await refreshGroupDetails(name); |
|||
new bootstrap.Modal(document.getElementById('viewGroupModal')).show(); |
|||
} |
|||
|
|||
async function refreshGroupDetails(requestedName) { |
|||
try { |
|||
const response = await fetch('/api/groups/' + encodeURIComponent(requestedName)); |
|||
if (!response.ok) throw new Error('Failed to fetch group'); |
|||
if (requestedName !== currentGroupName) return; // stale response |
|||
const group = await response.json(); |
|||
|
|||
// Render members using DOM APIs to prevent XSS |
|||
const membersList = document.getElementById('membersList'); |
|||
membersList.innerHTML = ''; |
|||
const membersTable = document.createElement('table'); |
|||
membersTable.className = 'table table-sm'; |
|||
const membersTbody = document.createElement('tbody'); |
|||
if (group.members && group.members.length > 0) { |
|||
for (const member of group.members) { |
|||
const tr = membersTbody.insertRow(); |
|||
const td1 = tr.insertCell(); |
|||
td1.textContent = member; |
|||
const td2 = tr.insertCell(); |
|||
const btn = document.createElement('button'); |
|||
btn.className = 'btn btn-sm btn-outline-danger'; |
|||
btn.onclick = () => removeMember(member); |
|||
btn.innerHTML = '<i class="fas fa-times"></i>'; |
|||
td2.appendChild(btn); |
|||
} |
|||
} else { |
|||
const tr = membersTbody.insertRow(); |
|||
const td = tr.insertCell(); |
|||
td.className = 'text-muted'; |
|||
td.textContent = 'No members'; |
|||
} |
|||
membersTable.appendChild(membersTbody); |
|||
membersList.appendChild(membersTable); |
|||
|
|||
// Render policies using DOM APIs to prevent XSS |
|||
const policiesList = document.getElementById('policiesList'); |
|||
policiesList.innerHTML = ''; |
|||
const policiesTable = document.createElement('table'); |
|||
policiesTable.className = 'table table-sm'; |
|||
const policiesTbody = document.createElement('tbody'); |
|||
if (group.policy_names && group.policy_names.length > 0) { |
|||
for (const policy of group.policy_names) { |
|||
const tr = policiesTbody.insertRow(); |
|||
const td1 = tr.insertCell(); |
|||
td1.textContent = policy; |
|||
const td2 = tr.insertCell(); |
|||
const btn = document.createElement('button'); |
|||
btn.className = 'btn btn-sm btn-outline-danger'; |
|||
btn.onclick = () => detachPolicy(policy); |
|||
btn.innerHTML = '<i class="fas fa-times"></i>'; |
|||
td2.appendChild(btn); |
|||
} |
|||
} else { |
|||
const tr = policiesTbody.insertRow(); |
|||
const td = tr.insertCell(); |
|||
td.className = 'text-muted'; |
|||
td.textContent = 'No policies attached'; |
|||
} |
|||
policiesTable.appendChild(policiesTbody); |
|||
policiesList.appendChild(policiesTable); |
|||
|
|||
// Update status toggle |
|||
document.getElementById('groupEnabledSwitch').checked = (group.status === 'enabled'); |
|||
} catch (error) { |
|||
console.error('Error fetching group details:', error); |
|||
} |
|||
} |
|||
|
|||
async function addMemberToGroup() { |
|||
const username = document.getElementById('addMemberSelect').value; |
|||
if (!username) return; |
|||
try { |
|||
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName) + '/members', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ username: username }) |
|||
}); |
|||
if (response.ok) { |
|||
await refreshGroupDetails(currentGroupName); |
|||
showAlert('Member added', 'success'); |
|||
} else { |
|||
const error = await response.json().catch(() => ({})); |
|||
showAlert('Failed to add member: ' + (error.error || 'Unknown error'), 'error'); |
|||
} |
|||
} catch (error) { |
|||
showAlert('Failed to add member: ' + error.message, 'error'); |
|||
} |
|||
} |
|||
|
|||
async function removeMember(username) { |
|||
try { |
|||
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName) + '/members/' + encodeURIComponent(username), { |
|||
method: 'DELETE' |
|||
}); |
|||
if (response.ok) { |
|||
await refreshGroupDetails(currentGroupName); |
|||
showAlert('Member removed', 'success'); |
|||
} else { |
|||
const error = await response.json().catch(() => ({})); |
|||
showAlert('Failed to remove member: ' + (error.error || 'Unknown error'), 'error'); |
|||
} |
|||
} catch (error) { |
|||
showAlert('Failed to remove member: ' + error.message, 'error'); |
|||
} |
|||
} |
|||
|
|||
async function attachPolicyToGroup() { |
|||
const policyName = document.getElementById('attachPolicySelect').value; |
|||
if (!policyName) return; |
|||
try { |
|||
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName) + '/policies', { |
|||
method: 'POST', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ policy_name: policyName }) |
|||
}); |
|||
if (response.ok) { |
|||
await refreshGroupDetails(currentGroupName); |
|||
showAlert('Policy attached', 'success'); |
|||
} else { |
|||
const error = await response.json().catch(() => ({})); |
|||
showAlert('Failed to attach policy: ' + (error.error || 'Unknown error'), 'error'); |
|||
} |
|||
} catch (error) { |
|||
showAlert('Failed to attach policy: ' + error.message, 'error'); |
|||
} |
|||
} |
|||
|
|||
async function detachPolicy(policyName) { |
|||
try { |
|||
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName) + '/policies/' + encodeURIComponent(policyName), { |
|||
method: 'DELETE' |
|||
}); |
|||
if (response.ok) { |
|||
await refreshGroupDetails(currentGroupName); |
|||
showAlert('Policy detached', 'success'); |
|||
} else { |
|||
const error = await response.json().catch(() => ({})); |
|||
showAlert('Failed to detach policy: ' + (error.error || 'Unknown error'), 'error'); |
|||
} |
|||
} catch (error) { |
|||
showAlert('Failed to detach policy: ' + error.message, 'error'); |
|||
} |
|||
} |
|||
|
|||
async function toggleGroupStatus() { |
|||
const enabled = document.getElementById('groupEnabledSwitch').checked; |
|||
try { |
|||
const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName) + '/status', { |
|||
method: 'PUT', |
|||
headers: { 'Content-Type': 'application/json' }, |
|||
body: JSON.stringify({ enabled: enabled }) |
|||
}); |
|||
if (response.ok) { |
|||
showAlert('Group status updated', 'success'); |
|||
} else { |
|||
const error = await response.json().catch(() => ({})); |
|||
showAlert('Failed to update status: ' + (error.error || 'Unknown error'), 'error'); |
|||
} |
|||
} catch (error) { |
|||
showAlert('Failed to update status: ' + error.message, 'error'); |
|||
} |
|||
} |
|||
|
|||
// Event delegation for group action buttons |
|||
document.addEventListener('click', function(e) { |
|||
const btn = e.target.closest('[data-action]'); |
|||
if (!btn) return; |
|||
const name = btn.dataset.groupName; |
|||
if (!name) return; |
|||
if (btn.dataset.action === 'view') viewGroup(name); |
|||
else if (btn.dataset.action === 'delete') deleteGroup(name); |
|||
}); |
|||
</script> |
|||
} |
|||
248
weed/admin/view/app/groups_templ.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -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) |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
|
|||
454
weed/pb/iam_pb/iam.pb.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue