mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-19 21:10:48 +02:00
* fix(s3): preserve exact policy document in embedded IAM PutUserPolicy/GetUserPolicy (#9008) The embedded IAM implementation (used when IAM requests go through the S3 gateway) discarded the original policy document on PutUserPolicy, storing only the lossy ident.Actions representation. GetUserPolicy then reconstructed the document from these coarse-grained actions, producing wildcard-expanded actions (s3:GetObject → s3:Get*), duplicates, and collapsed resources (array → single string). PR #9009 fixed this in the standalone IAM server (weed/iamapi/) but the embedded IAM (weed/s3api/) — which is the code path most users hit — had the same bugs. Changes: - Add InlinePolicyStore optional interface to credential store, with implementations for FilerEtcStore (uses existing PoliciesCollection), MemoryStore, and PropagatingCredentialStore. - Embedded IAM PutUserPolicy now persists the original policy document via CredentialManager.PutUserInlinePolicy for lossless round-trips. - Embedded IAM GetUserPolicy first tries the stored inline policy; only falls back to lossy reconstruction from ident.Actions when no stored document exists (e.g. policies created before this fix). - Fix the fallback reconstruction: add action deduplication and preserve resource paths verbatim (no more spurious /* appending). - Update DeleteUserPolicy/ListUserPolicies to use stored inline policies. * fix(s3): address PR review feedback for embedded IAM inline policies - Validate PolicyName is non-empty in PutUserPolicy and DeleteUserPolicy - Add recomputeActions() to aggregate ident.Actions from ALL stored inline policies on put/delete, fixing the issue where a second PutUserPolicy would overwrite the first policy's enforcement - Log errors from GetUserInlinePolicy in the GetUserPolicy fallback instead of silently ignoring them - Add initialization guards to MemoryStore GetUserInlinePolicy and ListUserInlinePolicies for consistency with other read methods * fix(s3): make inline policy persistence fatal and propagate recompute errors Address second round of review feedback: - recomputeActions() now returns ([]string, error) so callers can distinguish store failures from "no stored policies" and abort the mutation on transient errors instead of silently falling back. - PutUserInlinePolicy and DeleteUserInlinePolicy failures are now fatal: the API call returns ServiceFailure instead of logging and continuing, keeping ident.Actions and stored policy state in sync. * chore: gofmt weed/s3api/iceberg/handlers_oauth.go Pre-existing formatting issue from #9017; fixes S3 Tables Format Check CI.
464 lines
16 KiB
Go
464 lines
16 KiB
Go
package credential
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
var _ CredentialStore = &PropagatingCredentialStore{}
|
|
var _ PolicyManager = &PropagatingCredentialStore{}
|
|
|
|
type propagatingManagedPolicyLoader interface {
|
|
LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error)
|
|
}
|
|
|
|
type propagatingInlinePolicyLoader interface {
|
|
LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error)
|
|
}
|
|
|
|
type PropagatingCredentialStore struct {
|
|
CredentialStore
|
|
masterClient *wdclient.MasterClient
|
|
grpcDialOption grpc.DialOption
|
|
}
|
|
|
|
func NewPropagatingCredentialStore(upstream CredentialStore, masterClient *wdclient.MasterClient, grpcDialOption grpc.DialOption) *PropagatingCredentialStore {
|
|
return &PropagatingCredentialStore{
|
|
CredentialStore: upstream,
|
|
masterClient: masterClient,
|
|
grpcDialOption: grpcDialOption,
|
|
}
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) SetFilerAddressFunc(getFiler func() pb.ServerAddress, grpcDialOption grpc.DialOption) {
|
|
if setter, ok := s.CredentialStore.(FilerAddressSetter); ok {
|
|
setter.SetFilerAddressFunc(getFiler, grpcDialOption)
|
|
}
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) propagateChange(ctx context.Context, fn func(context.Context, s3_pb.SeaweedS3IamCacheClient) error) {
|
|
if s.masterClient == nil {
|
|
return
|
|
}
|
|
|
|
// List S3 servers
|
|
var s3Servers []string
|
|
err := s.masterClient.WithClient(false, func(client master_pb.SeaweedClient) error {
|
|
glog.V(4).Infof("IAM: listing S3 servers (FilerGroup: '%s')", s.masterClient.FilerGroup)
|
|
resp, err := client.ListClusterNodes(ctx, &master_pb.ListClusterNodesRequest{
|
|
ClientType: cluster.S3Type,
|
|
FilerGroup: s.masterClient.FilerGroup,
|
|
})
|
|
if err != nil {
|
|
glog.V(1).Infof("failed to list S3 servers: %v", err)
|
|
return err
|
|
}
|
|
for _, node := range resp.ClusterNodes {
|
|
s3Servers = append(s3Servers, node.Address)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
glog.V(1).Infof("failed to list s3 servers via master client: %v", err)
|
|
return
|
|
}
|
|
glog.V(1).Infof("IAM: propagating change to %d S3 servers: %v", len(s3Servers), s3Servers)
|
|
|
|
// Create context with timeout for the propagation process
|
|
propagateCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
var wg sync.WaitGroup
|
|
for _, server := range s3Servers {
|
|
wg.Add(1)
|
|
go func(server string) {
|
|
defer wg.Done()
|
|
err := pb.WithGrpcClient(false, 0, func(conn *grpc.ClientConn) error {
|
|
glog.V(4).Infof("IAM: successfully connected to S3 server %s for propagation", server)
|
|
client := s3_pb.NewSeaweedS3IamCacheClient(conn)
|
|
return fn(propagateCtx, client)
|
|
}, server, false, s.grpcDialOption)
|
|
if err != nil {
|
|
glog.V(1).Infof("failed to propagate change to s3 server %s: %v", server, err)
|
|
}
|
|
}(server)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) AttachUserPolicy(ctx context.Context, username string, policyName string) error {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.AttachUserPolicy %s -> %s", username, policyName)
|
|
if err := s.CredentialStore.AttachUserPolicy(ctx, username, policyName); err != nil {
|
|
return err
|
|
}
|
|
// Fetch updated identity to propagate
|
|
identity, err := s.CredentialStore.GetUser(ctx, username)
|
|
if err != nil {
|
|
glog.Warningf("failed to get user %s after attaching policy: %v", username, err)
|
|
return nil
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) DetachUserPolicy(ctx context.Context, username string, policyName string) error {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.DetachUserPolicy %s -> %s", username, policyName)
|
|
if err := s.CredentialStore.DetachUserPolicy(ctx, username, policyName); err != nil {
|
|
return err
|
|
}
|
|
// Fetch updated identity to propagate
|
|
identity, err := s.CredentialStore.GetUser(ctx, username)
|
|
if err != nil {
|
|
glog.Warningf("failed to get user %s after detaching policy: %v", username, err)
|
|
return nil
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error) {
|
|
return s.CredentialStore.ListAttachedUserPolicies(ctx, username)
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) CreateUser(ctx context.Context, identity *iam_pb.Identity) error {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateUser %s", identity.Name)
|
|
if err := s.CredentialStore.CreateUser(ctx, identity); err != nil {
|
|
return err
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) UpdateUser(ctx context.Context, username string, identity *iam_pb.Identity) error {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.UpdateUser %s", username)
|
|
if err := s.CredentialStore.UpdateUser(ctx, username, identity); err != nil {
|
|
return err
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
if _, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity}); err != nil {
|
|
return err
|
|
}
|
|
if username != identity.Name {
|
|
if _, err := client.RemoveIdentity(tx, &iam_pb.RemoveIdentityRequest{Username: username}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) DeleteUser(ctx context.Context, username string) error {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.DeleteUser %s", username)
|
|
if err := s.CredentialStore.DeleteUser(ctx, username); err != nil {
|
|
return err
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.RemoveIdentity(tx, &iam_pb.RemoveIdentityRequest{Username: username})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) CreateAccessKey(ctx context.Context, username string, credential *iam_pb.Credential) error {
|
|
if err := s.CredentialStore.CreateAccessKey(ctx, username, credential); err != nil {
|
|
return err
|
|
}
|
|
// Fetch updated identity to propagate
|
|
identity, err := s.CredentialStore.GetUser(ctx, username)
|
|
if err != nil {
|
|
glog.Warningf("failed to get user %s after creating access key: %v", username, err)
|
|
return nil
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) DeleteAccessKey(ctx context.Context, username string, accessKey string) error {
|
|
if err := s.CredentialStore.DeleteAccessKey(ctx, username, accessKey); err != nil {
|
|
return err
|
|
}
|
|
// Fetch updated identity to propagate
|
|
identity, err := s.CredentialStore.GetUser(ctx, username)
|
|
if err != nil {
|
|
glog.Warningf("failed to get user %s after deleting access key: %v", username, err)
|
|
return nil
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) PutPolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.PutPolicy %s", name)
|
|
if err := s.CredentialStore.PutPolicy(ctx, name, document); err != nil {
|
|
return err
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
content, err := json.Marshal(document)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = client.PutPolicy(tx, &iam_pb.PutPolicyRequest{Name: name, Content: string(content)})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) DeletePolicy(ctx context.Context, name string) error {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.DeletePolicy %s", name)
|
|
if err := s.CredentialStore.DeletePolicy(ctx, name); err != nil {
|
|
return err
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.DeletePolicy(tx, &iam_pb.DeletePolicyRequest{Name: name})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) ListPolicyNames(ctx context.Context) ([]string, error) {
|
|
return s.CredentialStore.ListPolicyNames(ctx)
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) LoadManagedPolicies(ctx context.Context) ([]*iam_pb.Policy, error) {
|
|
if loader, ok := s.CredentialStore.(propagatingManagedPolicyLoader); ok {
|
|
return loader.LoadManagedPolicies(ctx)
|
|
}
|
|
|
|
policies, err := s.CredentialStore.GetPolicies(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
managedPolicies := make([]*iam_pb.Policy, 0, len(policies))
|
|
for name, policyDocument := range policies {
|
|
content, err := json.Marshal(policyDocument)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
managedPolicies = append(managedPolicies, &iam_pb.Policy{
|
|
Name: name,
|
|
Content: string(content),
|
|
})
|
|
}
|
|
|
|
return managedPolicies, nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) LoadInlinePolicies(ctx context.Context) (map[string]map[string]policy_engine.PolicyDocument, error) {
|
|
if loader, ok := s.CredentialStore.(propagatingInlinePolicyLoader); ok {
|
|
return loader.LoadInlinePolicies(ctx)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) PutUserInlinePolicy(ctx context.Context, userName, policyName string, document policy_engine.PolicyDocument) error {
|
|
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
|
|
return store.PutUserInlinePolicy(ctx, userName, policyName, document)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) GetUserInlinePolicy(ctx context.Context, userName, policyName string) (*policy_engine.PolicyDocument, error) {
|
|
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
|
|
return store.GetUserInlinePolicy(ctx, userName, policyName)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) DeleteUserInlinePolicy(ctx context.Context, userName, policyName string) error {
|
|
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
|
|
return store.DeleteUserInlinePolicy(ctx, userName, policyName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) ListUserInlinePolicies(ctx context.Context, userName string) ([]string, error) {
|
|
if store, ok := s.CredentialStore.(InlinePolicyStore); ok {
|
|
return store.ListUserInlinePolicies(ctx, userName)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) CreatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
|
|
if pm, ok := s.CredentialStore.(PolicyManager); ok {
|
|
if err := pm.CreatePolicy(ctx, name, document); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := s.CredentialStore.PutPolicy(ctx, name, document); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
content, err := json.Marshal(document)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = client.PutPolicy(tx, &iam_pb.PutPolicyRequest{Name: name, Content: string(content)})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) UpdatePolicy(ctx context.Context, name string, document policy_engine.PolicyDocument) error {
|
|
if pm, ok := s.CredentialStore.(PolicyManager); ok {
|
|
if err := pm.UpdatePolicy(ctx, name, document); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := s.CredentialStore.PutPolicy(ctx, name, document); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
content, err := json.Marshal(document)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = client.PutPolicy(tx, &iam_pb.PutPolicyRequest{Name: name, Content: string(content)})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) CreateServiceAccount(ctx context.Context, sa *iam_pb.ServiceAccount) error {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateServiceAccount %s (parent: %s)", sa.Id, sa.ParentUser)
|
|
if err := s.CredentialStore.CreateServiceAccount(ctx, sa); err != nil {
|
|
return err
|
|
}
|
|
// Fetch parent identity to propagate
|
|
identity, err := s.CredentialStore.GetUser(ctx, sa.ParentUser)
|
|
if err != nil {
|
|
glog.Warningf("failed to get parent user %s after creating service account: %v", sa.ParentUser, err)
|
|
return nil
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) UpdateServiceAccount(ctx context.Context, id string, sa *iam_pb.ServiceAccount) error {
|
|
if err := s.CredentialStore.UpdateServiceAccount(ctx, id, sa); err != nil {
|
|
return err
|
|
}
|
|
// Fetch parent identity to propagate
|
|
identity, err := s.CredentialStore.GetUser(ctx, sa.ParentUser)
|
|
if err != nil {
|
|
glog.Warningf("failed to get parent user %s after updating service account: %v", sa.ParentUser, err)
|
|
return nil
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) DeleteServiceAccount(ctx context.Context, id string) error {
|
|
// Retrieve SA first to get ParentUser
|
|
sa, err := s.CredentialStore.GetServiceAccount(ctx, id)
|
|
if err != nil {
|
|
// If accessing non-existent SA, just proceed to delete (idempotency)
|
|
// But we can't propagate to parent...
|
|
if err := s.CredentialStore.DeleteServiceAccount(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := s.CredentialStore.DeleteServiceAccount(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Fetch parent identity to propagate
|
|
identity, err := s.CredentialStore.GetUser(ctx, sa.ParentUser)
|
|
if err != nil {
|
|
glog.Warningf("failed to get parent user %s after deleting service account: %v", sa.ParentUser, err)
|
|
return nil
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.PutIdentity(tx, &iam_pb.PutIdentityRequest{Identity: identity})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error {
|
|
if group != nil {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateGroup %s", group.Name)
|
|
}
|
|
if err := s.CredentialStore.CreateGroup(ctx, group); err != nil {
|
|
return err
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.PutGroup(tx, &iam_pb.PutGroupRequest{Group: group})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) {
|
|
return s.CredentialStore.GetGroup(ctx, groupName)
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) DeleteGroup(ctx context.Context, groupName string) error {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.DeleteGroup %s", groupName)
|
|
if err := s.CredentialStore.DeleteGroup(ctx, groupName); err != nil {
|
|
return err
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.RemoveGroup(tx, &iam_pb.RemoveGroupRequest{GroupName: groupName})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) ListGroups(ctx context.Context) ([]string, error) {
|
|
return s.CredentialStore.ListGroups(ctx)
|
|
}
|
|
|
|
func (s *PropagatingCredentialStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error {
|
|
if group != nil {
|
|
glog.V(4).Infof("IAM: PropagatingCredentialStore.UpdateGroup %s", group.Name)
|
|
}
|
|
if err := s.CredentialStore.UpdateGroup(ctx, group); err != nil {
|
|
return err
|
|
}
|
|
s.propagateChange(ctx, func(tx context.Context, client s3_pb.SeaweedS3IamCacheClient) error {
|
|
_, err := client.PutGroup(tx, &iam_pb.PutGroupRequest{Group: group})
|
|
return err
|
|
})
|
|
return nil
|
|
}
|