From 07d8ceafc77762ce5d1eedfe4f4a4e8a6a600164 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:22:36 -0700 Subject: [PATCH 01/72] iam: add Group message to protobuf schema Add Group message (name, members, policy_names, disabled) and add groups field to S3ApiConfiguration for IAM group management support (issue #7742). --- weed/pb/iam.proto | 8 + weed/pb/iam_pb/iam.pb.go | 454 +++++++++++++++++++++++---------------- 2 files changed, 277 insertions(+), 185 deletions(-) diff --git a/weed/pb/iam.proto b/weed/pb/iam.proto index 4972ae108..a989d1087 100644 --- a/weed/pb/iam.proto +++ b/weed/pb/iam.proto @@ -168,6 +168,14 @@ message S3ApiConfiguration { repeated Account accounts = 2; repeated ServiceAccount service_accounts = 3; repeated Policy policies = 4; + repeated Group groups = 5; +} + +message Group { + string name = 1; + repeated string members = 2; // usernames + repeated string policy_names = 3; // attached managed policy names + bool disabled = 4; } message Identity { diff --git a/weed/pb/iam_pb/iam.pb.go b/weed/pb/iam_pb/iam.pb.go index 243abda7e..7232d2762 100644 --- a/weed/pb/iam_pb/iam.pb.go +++ b/weed/pb/iam_pb/iam.pb.go @@ -1252,6 +1252,7 @@ type S3ApiConfiguration struct { Accounts []*Account `protobuf:"bytes,2,rep,name=accounts,proto3" json:"accounts,omitempty"` ServiceAccounts []*ServiceAccount `protobuf:"bytes,3,rep,name=service_accounts,json=serviceAccounts,proto3" json:"service_accounts,omitempty"` Policies []*Policy `protobuf:"bytes,4,rep,name=policies,proto3" json:"policies,omitempty"` + Groups []*Group `protobuf:"bytes,5,rep,name=groups,proto3" json:"groups,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1314,6 +1315,81 @@ func (x *S3ApiConfiguration) GetPolicies() []*Policy { return nil } +func (x *S3ApiConfiguration) GetGroups() []*Group { + if x != nil { + return x.Groups + } + return nil +} + +type Group struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Members []string `protobuf:"bytes,2,rep,name=members,proto3" json:"members,omitempty"` // usernames + PolicyNames []string `protobuf:"bytes,3,rep,name=policy_names,json=policyNames,proto3" json:"policy_names,omitempty"` // attached managed policy names + Disabled bool `protobuf:"varint,4,opt,name=disabled,proto3" json:"disabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Group) Reset() { + *x = Group{} + mi := &file_iam_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Group) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Group) ProtoMessage() {} + +func (x *Group) ProtoReflect() protoreflect.Message { + mi := &file_iam_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Group.ProtoReflect.Descriptor instead. +func (*Group) Descriptor() ([]byte, []int) { + return file_iam_proto_rawDescGZIP(), []int{29} +} + +func (x *Group) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Group) GetMembers() []string { + if x != nil { + return x.Members + } + return nil +} + +func (x *Group) GetPolicyNames() []string { + if x != nil { + return x.PolicyNames + } + return nil +} + +func (x *Group) GetDisabled() bool { + if x != nil { + return x.Disabled + } + return false +} + type Identity struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -1329,7 +1405,7 @@ type Identity struct { func (x *Identity) Reset() { *x = Identity{} - mi := &file_iam_proto_msgTypes[29] + mi := &file_iam_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1341,7 +1417,7 @@ func (x *Identity) String() string { func (*Identity) ProtoMessage() {} func (x *Identity) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[29] + mi := &file_iam_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1354,7 +1430,7 @@ func (x *Identity) ProtoReflect() protoreflect.Message { // Deprecated: Use Identity.ProtoReflect.Descriptor instead. func (*Identity) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{29} + return file_iam_proto_rawDescGZIP(), []int{30} } func (x *Identity) GetName() string { @@ -1417,7 +1493,7 @@ type Credential struct { func (x *Credential) Reset() { *x = Credential{} - mi := &file_iam_proto_msgTypes[30] + mi := &file_iam_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1429,7 +1505,7 @@ func (x *Credential) String() string { func (*Credential) ProtoMessage() {} func (x *Credential) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[30] + mi := &file_iam_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1442,7 +1518,7 @@ func (x *Credential) ProtoReflect() protoreflect.Message { // Deprecated: Use Credential.ProtoReflect.Descriptor instead. func (*Credential) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{30} + return file_iam_proto_rawDescGZIP(), []int{31} } func (x *Credential) GetAccessKey() string { @@ -1477,7 +1553,7 @@ type Account struct { func (x *Account) Reset() { *x = Account{} - mi := &file_iam_proto_msgTypes[31] + mi := &file_iam_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1489,7 +1565,7 @@ func (x *Account) String() string { func (*Account) ProtoMessage() {} func (x *Account) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[31] + mi := &file_iam_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1502,7 +1578,7 @@ func (x *Account) ProtoReflect() protoreflect.Message { // Deprecated: Use Account.ProtoReflect.Descriptor instead. func (*Account) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{31} + return file_iam_proto_rawDescGZIP(), []int{32} } func (x *Account) GetId() string { @@ -1545,7 +1621,7 @@ type ServiceAccount struct { func (x *ServiceAccount) Reset() { *x = ServiceAccount{} - mi := &file_iam_proto_msgTypes[32] + mi := &file_iam_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1557,7 +1633,7 @@ func (x *ServiceAccount) String() string { func (*ServiceAccount) ProtoMessage() {} func (x *ServiceAccount) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[32] + mi := &file_iam_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1570,7 +1646,7 @@ func (x *ServiceAccount) ProtoReflect() protoreflect.Message { // Deprecated: Use ServiceAccount.ProtoReflect.Descriptor instead. func (*ServiceAccount) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{32} + return file_iam_proto_rawDescGZIP(), []int{33} } func (x *ServiceAccount) GetId() string { @@ -1646,7 +1722,7 @@ type PutPolicyRequest struct { func (x *PutPolicyRequest) Reset() { *x = PutPolicyRequest{} - mi := &file_iam_proto_msgTypes[33] + mi := &file_iam_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1658,7 +1734,7 @@ func (x *PutPolicyRequest) String() string { func (*PutPolicyRequest) ProtoMessage() {} func (x *PutPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[33] + mi := &file_iam_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1671,7 +1747,7 @@ func (x *PutPolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PutPolicyRequest.ProtoReflect.Descriptor instead. func (*PutPolicyRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{33} + return file_iam_proto_rawDescGZIP(), []int{34} } func (x *PutPolicyRequest) GetName() string { @@ -1696,7 +1772,7 @@ type PutPolicyResponse struct { func (x *PutPolicyResponse) Reset() { *x = PutPolicyResponse{} - mi := &file_iam_proto_msgTypes[34] + mi := &file_iam_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1708,7 +1784,7 @@ func (x *PutPolicyResponse) String() string { func (*PutPolicyResponse) ProtoMessage() {} func (x *PutPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[34] + mi := &file_iam_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1721,7 +1797,7 @@ func (x *PutPolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PutPolicyResponse.ProtoReflect.Descriptor instead. func (*PutPolicyResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{34} + return file_iam_proto_rawDescGZIP(), []int{35} } type GetPolicyRequest struct { @@ -1733,7 +1809,7 @@ type GetPolicyRequest struct { func (x *GetPolicyRequest) Reset() { *x = GetPolicyRequest{} - mi := &file_iam_proto_msgTypes[35] + mi := &file_iam_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1745,7 +1821,7 @@ func (x *GetPolicyRequest) String() string { func (*GetPolicyRequest) ProtoMessage() {} func (x *GetPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[35] + mi := &file_iam_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1758,7 +1834,7 @@ func (x *GetPolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPolicyRequest.ProtoReflect.Descriptor instead. func (*GetPolicyRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{35} + return file_iam_proto_rawDescGZIP(), []int{36} } func (x *GetPolicyRequest) GetName() string { @@ -1778,7 +1854,7 @@ type GetPolicyResponse struct { func (x *GetPolicyResponse) Reset() { *x = GetPolicyResponse{} - mi := &file_iam_proto_msgTypes[36] + mi := &file_iam_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1790,7 +1866,7 @@ func (x *GetPolicyResponse) String() string { func (*GetPolicyResponse) ProtoMessage() {} func (x *GetPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[36] + mi := &file_iam_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1803,7 +1879,7 @@ func (x *GetPolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPolicyResponse.ProtoReflect.Descriptor instead. func (*GetPolicyResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{36} + return file_iam_proto_rawDescGZIP(), []int{37} } func (x *GetPolicyResponse) GetName() string { @@ -1828,7 +1904,7 @@ type ListPoliciesRequest struct { func (x *ListPoliciesRequest) Reset() { *x = ListPoliciesRequest{} - mi := &file_iam_proto_msgTypes[37] + mi := &file_iam_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1840,7 +1916,7 @@ func (x *ListPoliciesRequest) String() string { func (*ListPoliciesRequest) ProtoMessage() {} func (x *ListPoliciesRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[37] + mi := &file_iam_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1853,7 +1929,7 @@ func (x *ListPoliciesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListPoliciesRequest.ProtoReflect.Descriptor instead. func (*ListPoliciesRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{37} + return file_iam_proto_rawDescGZIP(), []int{38} } type ListPoliciesResponse struct { @@ -1865,7 +1941,7 @@ type ListPoliciesResponse struct { func (x *ListPoliciesResponse) Reset() { *x = ListPoliciesResponse{} - mi := &file_iam_proto_msgTypes[38] + mi := &file_iam_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1877,7 +1953,7 @@ func (x *ListPoliciesResponse) String() string { func (*ListPoliciesResponse) ProtoMessage() {} func (x *ListPoliciesResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[38] + mi := &file_iam_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1890,7 +1966,7 @@ func (x *ListPoliciesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListPoliciesResponse.ProtoReflect.Descriptor instead. func (*ListPoliciesResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{38} + return file_iam_proto_rawDescGZIP(), []int{39} } func (x *ListPoliciesResponse) GetPolicies() []*Policy { @@ -1909,7 +1985,7 @@ type DeletePolicyRequest struct { func (x *DeletePolicyRequest) Reset() { *x = DeletePolicyRequest{} - mi := &file_iam_proto_msgTypes[39] + mi := &file_iam_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1921,7 +1997,7 @@ func (x *DeletePolicyRequest) String() string { func (*DeletePolicyRequest) ProtoMessage() {} func (x *DeletePolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[39] + mi := &file_iam_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1934,7 +2010,7 @@ func (x *DeletePolicyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePolicyRequest.ProtoReflect.Descriptor instead. func (*DeletePolicyRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{39} + return file_iam_proto_rawDescGZIP(), []int{40} } func (x *DeletePolicyRequest) GetName() string { @@ -1952,7 +2028,7 @@ type DeletePolicyResponse struct { func (x *DeletePolicyResponse) Reset() { *x = DeletePolicyResponse{} - mi := &file_iam_proto_msgTypes[40] + mi := &file_iam_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1964,7 +2040,7 @@ func (x *DeletePolicyResponse) String() string { func (*DeletePolicyResponse) ProtoMessage() {} func (x *DeletePolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[40] + mi := &file_iam_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1977,7 +2053,7 @@ func (x *DeletePolicyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePolicyResponse.ProtoReflect.Descriptor instead. func (*DeletePolicyResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{40} + return file_iam_proto_rawDescGZIP(), []int{41} } type Policy struct { @@ -1990,7 +2066,7 @@ type Policy struct { func (x *Policy) Reset() { *x = Policy{} - mi := &file_iam_proto_msgTypes[41] + mi := &file_iam_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2002,7 +2078,7 @@ func (x *Policy) String() string { func (*Policy) ProtoMessage() {} func (x *Policy) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[41] + mi := &file_iam_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2015,7 +2091,7 @@ func (x *Policy) ProtoReflect() protoreflect.Message { // Deprecated: Use Policy.ProtoReflect.Descriptor instead. func (*Policy) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{41} + return file_iam_proto_rawDescGZIP(), []int{42} } func (x *Policy) GetName() string { @@ -2041,7 +2117,7 @@ type CreateServiceAccountRequest struct { func (x *CreateServiceAccountRequest) Reset() { *x = CreateServiceAccountRequest{} - mi := &file_iam_proto_msgTypes[42] + mi := &file_iam_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2053,7 +2129,7 @@ func (x *CreateServiceAccountRequest) String() string { func (*CreateServiceAccountRequest) ProtoMessage() {} func (x *CreateServiceAccountRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[42] + mi := &file_iam_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2066,7 +2142,7 @@ func (x *CreateServiceAccountRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateServiceAccountRequest.ProtoReflect.Descriptor instead. func (*CreateServiceAccountRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{42} + return file_iam_proto_rawDescGZIP(), []int{43} } func (x *CreateServiceAccountRequest) GetServiceAccount() *ServiceAccount { @@ -2084,7 +2160,7 @@ type CreateServiceAccountResponse struct { func (x *CreateServiceAccountResponse) Reset() { *x = CreateServiceAccountResponse{} - mi := &file_iam_proto_msgTypes[43] + mi := &file_iam_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2096,7 +2172,7 @@ func (x *CreateServiceAccountResponse) String() string { func (*CreateServiceAccountResponse) ProtoMessage() {} func (x *CreateServiceAccountResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[43] + mi := &file_iam_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2109,7 +2185,7 @@ func (x *CreateServiceAccountResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateServiceAccountResponse.ProtoReflect.Descriptor instead. func (*CreateServiceAccountResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{43} + return file_iam_proto_rawDescGZIP(), []int{44} } type UpdateServiceAccountRequest struct { @@ -2122,7 +2198,7 @@ type UpdateServiceAccountRequest struct { func (x *UpdateServiceAccountRequest) Reset() { *x = UpdateServiceAccountRequest{} - mi := &file_iam_proto_msgTypes[44] + mi := &file_iam_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2134,7 +2210,7 @@ func (x *UpdateServiceAccountRequest) String() string { func (*UpdateServiceAccountRequest) ProtoMessage() {} func (x *UpdateServiceAccountRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[44] + mi := &file_iam_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2147,7 +2223,7 @@ func (x *UpdateServiceAccountRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateServiceAccountRequest.ProtoReflect.Descriptor instead. func (*UpdateServiceAccountRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{44} + return file_iam_proto_rawDescGZIP(), []int{45} } func (x *UpdateServiceAccountRequest) GetId() string { @@ -2172,7 +2248,7 @@ type UpdateServiceAccountResponse struct { func (x *UpdateServiceAccountResponse) Reset() { *x = UpdateServiceAccountResponse{} - mi := &file_iam_proto_msgTypes[45] + mi := &file_iam_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2184,7 +2260,7 @@ func (x *UpdateServiceAccountResponse) String() string { func (*UpdateServiceAccountResponse) ProtoMessage() {} func (x *UpdateServiceAccountResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[45] + mi := &file_iam_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2197,7 +2273,7 @@ func (x *UpdateServiceAccountResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateServiceAccountResponse.ProtoReflect.Descriptor instead. func (*UpdateServiceAccountResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{45} + return file_iam_proto_rawDescGZIP(), []int{46} } type DeleteServiceAccountRequest struct { @@ -2209,7 +2285,7 @@ type DeleteServiceAccountRequest struct { func (x *DeleteServiceAccountRequest) Reset() { *x = DeleteServiceAccountRequest{} - mi := &file_iam_proto_msgTypes[46] + mi := &file_iam_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2221,7 +2297,7 @@ func (x *DeleteServiceAccountRequest) String() string { func (*DeleteServiceAccountRequest) ProtoMessage() {} func (x *DeleteServiceAccountRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[46] + mi := &file_iam_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2234,7 +2310,7 @@ func (x *DeleteServiceAccountRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteServiceAccountRequest.ProtoReflect.Descriptor instead. func (*DeleteServiceAccountRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{46} + return file_iam_proto_rawDescGZIP(), []int{47} } func (x *DeleteServiceAccountRequest) GetId() string { @@ -2252,7 +2328,7 @@ type DeleteServiceAccountResponse struct { func (x *DeleteServiceAccountResponse) Reset() { *x = DeleteServiceAccountResponse{} - mi := &file_iam_proto_msgTypes[47] + mi := &file_iam_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2264,7 +2340,7 @@ func (x *DeleteServiceAccountResponse) String() string { func (*DeleteServiceAccountResponse) ProtoMessage() {} func (x *DeleteServiceAccountResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[47] + mi := &file_iam_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2277,7 +2353,7 @@ func (x *DeleteServiceAccountResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteServiceAccountResponse.ProtoReflect.Descriptor instead. func (*DeleteServiceAccountResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{47} + return file_iam_proto_rawDescGZIP(), []int{48} } type GetServiceAccountRequest struct { @@ -2289,7 +2365,7 @@ type GetServiceAccountRequest struct { func (x *GetServiceAccountRequest) Reset() { *x = GetServiceAccountRequest{} - mi := &file_iam_proto_msgTypes[48] + mi := &file_iam_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2301,7 +2377,7 @@ func (x *GetServiceAccountRequest) String() string { func (*GetServiceAccountRequest) ProtoMessage() {} func (x *GetServiceAccountRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[48] + mi := &file_iam_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2314,7 +2390,7 @@ func (x *GetServiceAccountRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetServiceAccountRequest.ProtoReflect.Descriptor instead. func (*GetServiceAccountRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{48} + return file_iam_proto_rawDescGZIP(), []int{49} } func (x *GetServiceAccountRequest) GetId() string { @@ -2333,7 +2409,7 @@ type GetServiceAccountResponse struct { func (x *GetServiceAccountResponse) Reset() { *x = GetServiceAccountResponse{} - mi := &file_iam_proto_msgTypes[49] + mi := &file_iam_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2345,7 +2421,7 @@ func (x *GetServiceAccountResponse) String() string { func (*GetServiceAccountResponse) ProtoMessage() {} func (x *GetServiceAccountResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[49] + mi := &file_iam_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2358,7 +2434,7 @@ func (x *GetServiceAccountResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetServiceAccountResponse.ProtoReflect.Descriptor instead. func (*GetServiceAccountResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{49} + return file_iam_proto_rawDescGZIP(), []int{50} } func (x *GetServiceAccountResponse) GetServiceAccount() *ServiceAccount { @@ -2376,7 +2452,7 @@ type ListServiceAccountsRequest struct { func (x *ListServiceAccountsRequest) Reset() { *x = ListServiceAccountsRequest{} - mi := &file_iam_proto_msgTypes[50] + mi := &file_iam_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2388,7 +2464,7 @@ func (x *ListServiceAccountsRequest) String() string { func (*ListServiceAccountsRequest) ProtoMessage() {} func (x *ListServiceAccountsRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[50] + mi := &file_iam_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2401,7 +2477,7 @@ func (x *ListServiceAccountsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListServiceAccountsRequest.ProtoReflect.Descriptor instead. func (*ListServiceAccountsRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{50} + return file_iam_proto_rawDescGZIP(), []int{51} } type ListServiceAccountsResponse struct { @@ -2413,7 +2489,7 @@ type ListServiceAccountsResponse struct { func (x *ListServiceAccountsResponse) Reset() { *x = ListServiceAccountsResponse{} - mi := &file_iam_proto_msgTypes[51] + mi := &file_iam_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2425,7 +2501,7 @@ func (x *ListServiceAccountsResponse) String() string { func (*ListServiceAccountsResponse) ProtoMessage() {} func (x *ListServiceAccountsResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[51] + mi := &file_iam_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2438,7 +2514,7 @@ func (x *ListServiceAccountsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListServiceAccountsResponse.ProtoReflect.Descriptor instead. func (*ListServiceAccountsResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{51} + return file_iam_proto_rawDescGZIP(), []int{52} } func (x *ListServiceAccountsResponse) GetServiceAccounts() []*ServiceAccount { @@ -2457,7 +2533,7 @@ type GetServiceAccountByAccessKeyRequest struct { func (x *GetServiceAccountByAccessKeyRequest) Reset() { *x = GetServiceAccountByAccessKeyRequest{} - mi := &file_iam_proto_msgTypes[52] + mi := &file_iam_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2469,7 +2545,7 @@ func (x *GetServiceAccountByAccessKeyRequest) String() string { func (*GetServiceAccountByAccessKeyRequest) ProtoMessage() {} func (x *GetServiceAccountByAccessKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[52] + mi := &file_iam_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2482,7 +2558,7 @@ func (x *GetServiceAccountByAccessKeyRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use GetServiceAccountByAccessKeyRequest.ProtoReflect.Descriptor instead. func (*GetServiceAccountByAccessKeyRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{52} + return file_iam_proto_rawDescGZIP(), []int{53} } func (x *GetServiceAccountByAccessKeyRequest) GetAccessKey() string { @@ -2501,7 +2577,7 @@ type GetServiceAccountByAccessKeyResponse struct { func (x *GetServiceAccountByAccessKeyResponse) Reset() { *x = GetServiceAccountByAccessKeyResponse{} - mi := &file_iam_proto_msgTypes[53] + mi := &file_iam_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2513,7 +2589,7 @@ func (x *GetServiceAccountByAccessKeyResponse) String() string { func (*GetServiceAccountByAccessKeyResponse) ProtoMessage() {} func (x *GetServiceAccountByAccessKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[53] + mi := &file_iam_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2526,7 +2602,7 @@ func (x *GetServiceAccountByAccessKeyResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use GetServiceAccountByAccessKeyResponse.ProtoReflect.Descriptor instead. func (*GetServiceAccountByAccessKeyResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{53} + return file_iam_proto_rawDescGZIP(), []int{54} } func (x *GetServiceAccountByAccessKeyResponse) GetServiceAccount() *ServiceAccount { @@ -2545,7 +2621,7 @@ type PutIdentityRequest struct { func (x *PutIdentityRequest) Reset() { *x = PutIdentityRequest{} - mi := &file_iam_proto_msgTypes[54] + mi := &file_iam_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2557,7 +2633,7 @@ func (x *PutIdentityRequest) String() string { func (*PutIdentityRequest) ProtoMessage() {} func (x *PutIdentityRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[54] + mi := &file_iam_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2570,7 +2646,7 @@ func (x *PutIdentityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PutIdentityRequest.ProtoReflect.Descriptor instead. func (*PutIdentityRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{54} + return file_iam_proto_rawDescGZIP(), []int{55} } func (x *PutIdentityRequest) GetIdentity() *Identity { @@ -2588,7 +2664,7 @@ type PutIdentityResponse struct { func (x *PutIdentityResponse) Reset() { *x = PutIdentityResponse{} - mi := &file_iam_proto_msgTypes[55] + mi := &file_iam_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2600,7 +2676,7 @@ func (x *PutIdentityResponse) String() string { func (*PutIdentityResponse) ProtoMessage() {} func (x *PutIdentityResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[55] + mi := &file_iam_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2613,7 +2689,7 @@ func (x *PutIdentityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PutIdentityResponse.ProtoReflect.Descriptor instead. func (*PutIdentityResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{55} + return file_iam_proto_rawDescGZIP(), []int{56} } type RemoveIdentityRequest struct { @@ -2625,7 +2701,7 @@ type RemoveIdentityRequest struct { func (x *RemoveIdentityRequest) Reset() { *x = RemoveIdentityRequest{} - mi := &file_iam_proto_msgTypes[56] + mi := &file_iam_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2637,7 +2713,7 @@ func (x *RemoveIdentityRequest) String() string { func (*RemoveIdentityRequest) ProtoMessage() {} func (x *RemoveIdentityRequest) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[56] + mi := &file_iam_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2650,7 +2726,7 @@ func (x *RemoveIdentityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveIdentityRequest.ProtoReflect.Descriptor instead. func (*RemoveIdentityRequest) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{56} + return file_iam_proto_rawDescGZIP(), []int{57} } func (x *RemoveIdentityRequest) GetUsername() string { @@ -2668,7 +2744,7 @@ type RemoveIdentityResponse struct { func (x *RemoveIdentityResponse) Reset() { *x = RemoveIdentityResponse{} - mi := &file_iam_proto_msgTypes[57] + mi := &file_iam_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2680,7 +2756,7 @@ func (x *RemoveIdentityResponse) String() string { func (*RemoveIdentityResponse) ProtoMessage() {} func (x *RemoveIdentityResponse) ProtoReflect() protoreflect.Message { - mi := &file_iam_proto_msgTypes[57] + mi := &file_iam_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2693,7 +2769,7 @@ func (x *RemoveIdentityResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveIdentityResponse.ProtoReflect.Descriptor instead. func (*RemoveIdentityResponse) Descriptor() ([]byte, []int) { - return file_iam_proto_rawDescGZIP(), []int{57} + return file_iam_proto_rawDescGZIP(), []int{58} } var File_iam_proto protoreflect.FileDescriptor @@ -2764,14 +2840,20 @@ const file_iam_proto_rawDesc = "" + "\busername\x18\x01 \x01(\tR\busername\x12\x1f\n" + "\vpolicy_name\x18\x02 \x01(\tR\n" + "policyName\"\x1a\n" + - "\x18DeleteUserPolicyResponse\"\xe2\x01\n" + + "\x18DeleteUserPolicyResponse\"\x89\x02\n" + "\x12S3ApiConfiguration\x120\n" + "\n" + "identities\x18\x01 \x03(\v2\x10.iam_pb.IdentityR\n" + "identities\x12+\n" + "\baccounts\x18\x02 \x03(\v2\x0f.iam_pb.AccountR\baccounts\x12A\n" + "\x10service_accounts\x18\x03 \x03(\v2\x16.iam_pb.ServiceAccountR\x0fserviceAccounts\x12*\n" + - "\bpolicies\x18\x04 \x03(\v2\x0e.iam_pb.PolicyR\bpolicies\"\x88\x02\n" + + "\bpolicies\x18\x04 \x03(\v2\x0e.iam_pb.PolicyR\bpolicies\x12%\n" + + "\x06groups\x18\x05 \x03(\v2\r.iam_pb.GroupR\x06groups\"t\n" + + "\x05Group\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\amembers\x18\x02 \x03(\tR\amembers\x12!\n" + + "\fpolicy_names\x18\x03 \x03(\tR\vpolicyNames\x12\x1a\n" + + "\bdisabled\x18\x04 \x01(\bR\bdisabled\"\x88\x02\n" + "\bIdentity\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x124\n" + "\vcredentials\x18\x02 \x03(\v2\x12.iam_pb.CredentialR\vcredentials\x12\x18\n" + @@ -2892,7 +2974,7 @@ func file_iam_proto_rawDescGZIP() []byte { return file_iam_proto_rawDescData } -var file_iam_proto_msgTypes = make([]protoimpl.MessageInfo, 58) +var file_iam_proto_msgTypes = make([]protoimpl.MessageInfo, 59) var file_iam_proto_goTypes = []any{ (*GetConfigurationRequest)(nil), // 0: iam_pb.GetConfigurationRequest (*GetConfigurationResponse)(nil), // 1: iam_pb.GetConfigurationResponse @@ -2923,104 +3005,106 @@ var file_iam_proto_goTypes = []any{ (*DeleteUserPolicyRequest)(nil), // 26: iam_pb.DeleteUserPolicyRequest (*DeleteUserPolicyResponse)(nil), // 27: iam_pb.DeleteUserPolicyResponse (*S3ApiConfiguration)(nil), // 28: iam_pb.S3ApiConfiguration - (*Identity)(nil), // 29: iam_pb.Identity - (*Credential)(nil), // 30: iam_pb.Credential - (*Account)(nil), // 31: iam_pb.Account - (*ServiceAccount)(nil), // 32: iam_pb.ServiceAccount - (*PutPolicyRequest)(nil), // 33: iam_pb.PutPolicyRequest - (*PutPolicyResponse)(nil), // 34: iam_pb.PutPolicyResponse - (*GetPolicyRequest)(nil), // 35: iam_pb.GetPolicyRequest - (*GetPolicyResponse)(nil), // 36: iam_pb.GetPolicyResponse - (*ListPoliciesRequest)(nil), // 37: iam_pb.ListPoliciesRequest - (*ListPoliciesResponse)(nil), // 38: iam_pb.ListPoliciesResponse - (*DeletePolicyRequest)(nil), // 39: iam_pb.DeletePolicyRequest - (*DeletePolicyResponse)(nil), // 40: iam_pb.DeletePolicyResponse - (*Policy)(nil), // 41: iam_pb.Policy - (*CreateServiceAccountRequest)(nil), // 42: iam_pb.CreateServiceAccountRequest - (*CreateServiceAccountResponse)(nil), // 43: iam_pb.CreateServiceAccountResponse - (*UpdateServiceAccountRequest)(nil), // 44: iam_pb.UpdateServiceAccountRequest - (*UpdateServiceAccountResponse)(nil), // 45: iam_pb.UpdateServiceAccountResponse - (*DeleteServiceAccountRequest)(nil), // 46: iam_pb.DeleteServiceAccountRequest - (*DeleteServiceAccountResponse)(nil), // 47: iam_pb.DeleteServiceAccountResponse - (*GetServiceAccountRequest)(nil), // 48: iam_pb.GetServiceAccountRequest - (*GetServiceAccountResponse)(nil), // 49: iam_pb.GetServiceAccountResponse - (*ListServiceAccountsRequest)(nil), // 50: iam_pb.ListServiceAccountsRequest - (*ListServiceAccountsResponse)(nil), // 51: iam_pb.ListServiceAccountsResponse - (*GetServiceAccountByAccessKeyRequest)(nil), // 52: iam_pb.GetServiceAccountByAccessKeyRequest - (*GetServiceAccountByAccessKeyResponse)(nil), // 53: iam_pb.GetServiceAccountByAccessKeyResponse - (*PutIdentityRequest)(nil), // 54: iam_pb.PutIdentityRequest - (*PutIdentityResponse)(nil), // 55: iam_pb.PutIdentityResponse - (*RemoveIdentityRequest)(nil), // 56: iam_pb.RemoveIdentityRequest - (*RemoveIdentityResponse)(nil), // 57: iam_pb.RemoveIdentityResponse + (*Group)(nil), // 29: iam_pb.Group + (*Identity)(nil), // 30: iam_pb.Identity + (*Credential)(nil), // 31: iam_pb.Credential + (*Account)(nil), // 32: iam_pb.Account + (*ServiceAccount)(nil), // 33: iam_pb.ServiceAccount + (*PutPolicyRequest)(nil), // 34: iam_pb.PutPolicyRequest + (*PutPolicyResponse)(nil), // 35: iam_pb.PutPolicyResponse + (*GetPolicyRequest)(nil), // 36: iam_pb.GetPolicyRequest + (*GetPolicyResponse)(nil), // 37: iam_pb.GetPolicyResponse + (*ListPoliciesRequest)(nil), // 38: iam_pb.ListPoliciesRequest + (*ListPoliciesResponse)(nil), // 39: iam_pb.ListPoliciesResponse + (*DeletePolicyRequest)(nil), // 40: iam_pb.DeletePolicyRequest + (*DeletePolicyResponse)(nil), // 41: iam_pb.DeletePolicyResponse + (*Policy)(nil), // 42: iam_pb.Policy + (*CreateServiceAccountRequest)(nil), // 43: iam_pb.CreateServiceAccountRequest + (*CreateServiceAccountResponse)(nil), // 44: iam_pb.CreateServiceAccountResponse + (*UpdateServiceAccountRequest)(nil), // 45: iam_pb.UpdateServiceAccountRequest + (*UpdateServiceAccountResponse)(nil), // 46: iam_pb.UpdateServiceAccountResponse + (*DeleteServiceAccountRequest)(nil), // 47: iam_pb.DeleteServiceAccountRequest + (*DeleteServiceAccountResponse)(nil), // 48: iam_pb.DeleteServiceAccountResponse + (*GetServiceAccountRequest)(nil), // 49: iam_pb.GetServiceAccountRequest + (*GetServiceAccountResponse)(nil), // 50: iam_pb.GetServiceAccountResponse + (*ListServiceAccountsRequest)(nil), // 51: iam_pb.ListServiceAccountsRequest + (*ListServiceAccountsResponse)(nil), // 52: iam_pb.ListServiceAccountsResponse + (*GetServiceAccountByAccessKeyRequest)(nil), // 53: iam_pb.GetServiceAccountByAccessKeyRequest + (*GetServiceAccountByAccessKeyResponse)(nil), // 54: iam_pb.GetServiceAccountByAccessKeyResponse + (*PutIdentityRequest)(nil), // 55: iam_pb.PutIdentityRequest + (*PutIdentityResponse)(nil), // 56: iam_pb.PutIdentityResponse + (*RemoveIdentityRequest)(nil), // 57: iam_pb.RemoveIdentityRequest + (*RemoveIdentityResponse)(nil), // 58: iam_pb.RemoveIdentityResponse } var file_iam_proto_depIdxs = []int32{ 28, // 0: iam_pb.GetConfigurationResponse.configuration:type_name -> iam_pb.S3ApiConfiguration 28, // 1: iam_pb.PutConfigurationRequest.configuration:type_name -> iam_pb.S3ApiConfiguration - 29, // 2: iam_pb.CreateUserRequest.identity:type_name -> iam_pb.Identity - 29, // 3: iam_pb.GetUserResponse.identity:type_name -> iam_pb.Identity - 29, // 4: iam_pb.UpdateUserRequest.identity:type_name -> iam_pb.Identity - 30, // 5: iam_pb.CreateAccessKeyRequest.credential:type_name -> iam_pb.Credential - 29, // 6: iam_pb.GetUserByAccessKeyResponse.identity:type_name -> iam_pb.Identity - 30, // 7: iam_pb.ListAccessKeysResponse.access_keys:type_name -> iam_pb.Credential - 29, // 8: iam_pb.S3ApiConfiguration.identities:type_name -> iam_pb.Identity - 31, // 9: iam_pb.S3ApiConfiguration.accounts:type_name -> iam_pb.Account - 32, // 10: iam_pb.S3ApiConfiguration.service_accounts:type_name -> iam_pb.ServiceAccount - 41, // 11: iam_pb.S3ApiConfiguration.policies:type_name -> iam_pb.Policy - 30, // 12: iam_pb.Identity.credentials:type_name -> iam_pb.Credential - 31, // 13: iam_pb.Identity.account:type_name -> iam_pb.Account - 30, // 14: iam_pb.ServiceAccount.credential:type_name -> iam_pb.Credential - 41, // 15: iam_pb.ListPoliciesResponse.policies:type_name -> iam_pb.Policy - 32, // 16: iam_pb.CreateServiceAccountRequest.service_account:type_name -> iam_pb.ServiceAccount - 32, // 17: iam_pb.UpdateServiceAccountRequest.service_account:type_name -> iam_pb.ServiceAccount - 32, // 18: iam_pb.GetServiceAccountResponse.service_account:type_name -> iam_pb.ServiceAccount - 32, // 19: iam_pb.ListServiceAccountsResponse.service_accounts:type_name -> iam_pb.ServiceAccount - 32, // 20: iam_pb.GetServiceAccountByAccessKeyResponse.service_account:type_name -> iam_pb.ServiceAccount - 29, // 21: iam_pb.PutIdentityRequest.identity:type_name -> iam_pb.Identity - 0, // 22: iam_pb.SeaweedIdentityAccessManagement.GetConfiguration:input_type -> iam_pb.GetConfigurationRequest - 2, // 23: iam_pb.SeaweedIdentityAccessManagement.PutConfiguration:input_type -> iam_pb.PutConfigurationRequest - 4, // 24: iam_pb.SeaweedIdentityAccessManagement.CreateUser:input_type -> iam_pb.CreateUserRequest - 6, // 25: iam_pb.SeaweedIdentityAccessManagement.GetUser:input_type -> iam_pb.GetUserRequest - 8, // 26: iam_pb.SeaweedIdentityAccessManagement.UpdateUser:input_type -> iam_pb.UpdateUserRequest - 10, // 27: iam_pb.SeaweedIdentityAccessManagement.DeleteUser:input_type -> iam_pb.DeleteUserRequest - 12, // 28: iam_pb.SeaweedIdentityAccessManagement.ListUsers:input_type -> iam_pb.ListUsersRequest - 14, // 29: iam_pb.SeaweedIdentityAccessManagement.CreateAccessKey:input_type -> iam_pb.CreateAccessKeyRequest - 16, // 30: iam_pb.SeaweedIdentityAccessManagement.DeleteAccessKey:input_type -> iam_pb.DeleteAccessKeyRequest - 18, // 31: iam_pb.SeaweedIdentityAccessManagement.GetUserByAccessKey:input_type -> iam_pb.GetUserByAccessKeyRequest - 33, // 32: iam_pb.SeaweedIdentityAccessManagement.PutPolicy:input_type -> iam_pb.PutPolicyRequest - 35, // 33: iam_pb.SeaweedIdentityAccessManagement.GetPolicy:input_type -> iam_pb.GetPolicyRequest - 37, // 34: iam_pb.SeaweedIdentityAccessManagement.ListPolicies:input_type -> iam_pb.ListPoliciesRequest - 39, // 35: iam_pb.SeaweedIdentityAccessManagement.DeletePolicy:input_type -> iam_pb.DeletePolicyRequest - 42, // 36: iam_pb.SeaweedIdentityAccessManagement.CreateServiceAccount:input_type -> iam_pb.CreateServiceAccountRequest - 44, // 37: iam_pb.SeaweedIdentityAccessManagement.UpdateServiceAccount:input_type -> iam_pb.UpdateServiceAccountRequest - 46, // 38: iam_pb.SeaweedIdentityAccessManagement.DeleteServiceAccount:input_type -> iam_pb.DeleteServiceAccountRequest - 48, // 39: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccount:input_type -> iam_pb.GetServiceAccountRequest - 50, // 40: iam_pb.SeaweedIdentityAccessManagement.ListServiceAccounts:input_type -> iam_pb.ListServiceAccountsRequest - 52, // 41: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccountByAccessKey:input_type -> iam_pb.GetServiceAccountByAccessKeyRequest - 1, // 42: iam_pb.SeaweedIdentityAccessManagement.GetConfiguration:output_type -> iam_pb.GetConfigurationResponse - 3, // 43: iam_pb.SeaweedIdentityAccessManagement.PutConfiguration:output_type -> iam_pb.PutConfigurationResponse - 5, // 44: iam_pb.SeaweedIdentityAccessManagement.CreateUser:output_type -> iam_pb.CreateUserResponse - 7, // 45: iam_pb.SeaweedIdentityAccessManagement.GetUser:output_type -> iam_pb.GetUserResponse - 9, // 46: iam_pb.SeaweedIdentityAccessManagement.UpdateUser:output_type -> iam_pb.UpdateUserResponse - 11, // 47: iam_pb.SeaweedIdentityAccessManagement.DeleteUser:output_type -> iam_pb.DeleteUserResponse - 13, // 48: iam_pb.SeaweedIdentityAccessManagement.ListUsers:output_type -> iam_pb.ListUsersResponse - 15, // 49: iam_pb.SeaweedIdentityAccessManagement.CreateAccessKey:output_type -> iam_pb.CreateAccessKeyResponse - 17, // 50: iam_pb.SeaweedIdentityAccessManagement.DeleteAccessKey:output_type -> iam_pb.DeleteAccessKeyResponse - 19, // 51: iam_pb.SeaweedIdentityAccessManagement.GetUserByAccessKey:output_type -> iam_pb.GetUserByAccessKeyResponse - 34, // 52: iam_pb.SeaweedIdentityAccessManagement.PutPolicy:output_type -> iam_pb.PutPolicyResponse - 36, // 53: iam_pb.SeaweedIdentityAccessManagement.GetPolicy:output_type -> iam_pb.GetPolicyResponse - 38, // 54: iam_pb.SeaweedIdentityAccessManagement.ListPolicies:output_type -> iam_pb.ListPoliciesResponse - 40, // 55: iam_pb.SeaweedIdentityAccessManagement.DeletePolicy:output_type -> iam_pb.DeletePolicyResponse - 43, // 56: iam_pb.SeaweedIdentityAccessManagement.CreateServiceAccount:output_type -> iam_pb.CreateServiceAccountResponse - 45, // 57: iam_pb.SeaweedIdentityAccessManagement.UpdateServiceAccount:output_type -> iam_pb.UpdateServiceAccountResponse - 47, // 58: iam_pb.SeaweedIdentityAccessManagement.DeleteServiceAccount:output_type -> iam_pb.DeleteServiceAccountResponse - 49, // 59: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccount:output_type -> iam_pb.GetServiceAccountResponse - 51, // 60: iam_pb.SeaweedIdentityAccessManagement.ListServiceAccounts:output_type -> iam_pb.ListServiceAccountsResponse - 53, // 61: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccountByAccessKey:output_type -> iam_pb.GetServiceAccountByAccessKeyResponse - 42, // [42:62] is the sub-list for method output_type - 22, // [22:42] is the sub-list for method input_type - 22, // [22:22] is the sub-list for extension type_name - 22, // [22:22] is the sub-list for extension extendee - 0, // [0:22] is the sub-list for field type_name + 30, // 2: iam_pb.CreateUserRequest.identity:type_name -> iam_pb.Identity + 30, // 3: iam_pb.GetUserResponse.identity:type_name -> iam_pb.Identity + 30, // 4: iam_pb.UpdateUserRequest.identity:type_name -> iam_pb.Identity + 31, // 5: iam_pb.CreateAccessKeyRequest.credential:type_name -> iam_pb.Credential + 30, // 6: iam_pb.GetUserByAccessKeyResponse.identity:type_name -> iam_pb.Identity + 31, // 7: iam_pb.ListAccessKeysResponse.access_keys:type_name -> iam_pb.Credential + 30, // 8: iam_pb.S3ApiConfiguration.identities:type_name -> iam_pb.Identity + 32, // 9: iam_pb.S3ApiConfiguration.accounts:type_name -> iam_pb.Account + 33, // 10: iam_pb.S3ApiConfiguration.service_accounts:type_name -> iam_pb.ServiceAccount + 42, // 11: iam_pb.S3ApiConfiguration.policies:type_name -> iam_pb.Policy + 29, // 12: iam_pb.S3ApiConfiguration.groups:type_name -> iam_pb.Group + 31, // 13: iam_pb.Identity.credentials:type_name -> iam_pb.Credential + 32, // 14: iam_pb.Identity.account:type_name -> iam_pb.Account + 31, // 15: iam_pb.ServiceAccount.credential:type_name -> iam_pb.Credential + 42, // 16: iam_pb.ListPoliciesResponse.policies:type_name -> iam_pb.Policy + 33, // 17: iam_pb.CreateServiceAccountRequest.service_account:type_name -> iam_pb.ServiceAccount + 33, // 18: iam_pb.UpdateServiceAccountRequest.service_account:type_name -> iam_pb.ServiceAccount + 33, // 19: iam_pb.GetServiceAccountResponse.service_account:type_name -> iam_pb.ServiceAccount + 33, // 20: iam_pb.ListServiceAccountsResponse.service_accounts:type_name -> iam_pb.ServiceAccount + 33, // 21: iam_pb.GetServiceAccountByAccessKeyResponse.service_account:type_name -> iam_pb.ServiceAccount + 30, // 22: iam_pb.PutIdentityRequest.identity:type_name -> iam_pb.Identity + 0, // 23: iam_pb.SeaweedIdentityAccessManagement.GetConfiguration:input_type -> iam_pb.GetConfigurationRequest + 2, // 24: iam_pb.SeaweedIdentityAccessManagement.PutConfiguration:input_type -> iam_pb.PutConfigurationRequest + 4, // 25: iam_pb.SeaweedIdentityAccessManagement.CreateUser:input_type -> iam_pb.CreateUserRequest + 6, // 26: iam_pb.SeaweedIdentityAccessManagement.GetUser:input_type -> iam_pb.GetUserRequest + 8, // 27: iam_pb.SeaweedIdentityAccessManagement.UpdateUser:input_type -> iam_pb.UpdateUserRequest + 10, // 28: iam_pb.SeaweedIdentityAccessManagement.DeleteUser:input_type -> iam_pb.DeleteUserRequest + 12, // 29: iam_pb.SeaweedIdentityAccessManagement.ListUsers:input_type -> iam_pb.ListUsersRequest + 14, // 30: iam_pb.SeaweedIdentityAccessManagement.CreateAccessKey:input_type -> iam_pb.CreateAccessKeyRequest + 16, // 31: iam_pb.SeaweedIdentityAccessManagement.DeleteAccessKey:input_type -> iam_pb.DeleteAccessKeyRequest + 18, // 32: iam_pb.SeaweedIdentityAccessManagement.GetUserByAccessKey:input_type -> iam_pb.GetUserByAccessKeyRequest + 34, // 33: iam_pb.SeaweedIdentityAccessManagement.PutPolicy:input_type -> iam_pb.PutPolicyRequest + 36, // 34: iam_pb.SeaweedIdentityAccessManagement.GetPolicy:input_type -> iam_pb.GetPolicyRequest + 38, // 35: iam_pb.SeaweedIdentityAccessManagement.ListPolicies:input_type -> iam_pb.ListPoliciesRequest + 40, // 36: iam_pb.SeaweedIdentityAccessManagement.DeletePolicy:input_type -> iam_pb.DeletePolicyRequest + 43, // 37: iam_pb.SeaweedIdentityAccessManagement.CreateServiceAccount:input_type -> iam_pb.CreateServiceAccountRequest + 45, // 38: iam_pb.SeaweedIdentityAccessManagement.UpdateServiceAccount:input_type -> iam_pb.UpdateServiceAccountRequest + 47, // 39: iam_pb.SeaweedIdentityAccessManagement.DeleteServiceAccount:input_type -> iam_pb.DeleteServiceAccountRequest + 49, // 40: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccount:input_type -> iam_pb.GetServiceAccountRequest + 51, // 41: iam_pb.SeaweedIdentityAccessManagement.ListServiceAccounts:input_type -> iam_pb.ListServiceAccountsRequest + 53, // 42: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccountByAccessKey:input_type -> iam_pb.GetServiceAccountByAccessKeyRequest + 1, // 43: iam_pb.SeaweedIdentityAccessManagement.GetConfiguration:output_type -> iam_pb.GetConfigurationResponse + 3, // 44: iam_pb.SeaweedIdentityAccessManagement.PutConfiguration:output_type -> iam_pb.PutConfigurationResponse + 5, // 45: iam_pb.SeaweedIdentityAccessManagement.CreateUser:output_type -> iam_pb.CreateUserResponse + 7, // 46: iam_pb.SeaweedIdentityAccessManagement.GetUser:output_type -> iam_pb.GetUserResponse + 9, // 47: iam_pb.SeaweedIdentityAccessManagement.UpdateUser:output_type -> iam_pb.UpdateUserResponse + 11, // 48: iam_pb.SeaweedIdentityAccessManagement.DeleteUser:output_type -> iam_pb.DeleteUserResponse + 13, // 49: iam_pb.SeaweedIdentityAccessManagement.ListUsers:output_type -> iam_pb.ListUsersResponse + 15, // 50: iam_pb.SeaweedIdentityAccessManagement.CreateAccessKey:output_type -> iam_pb.CreateAccessKeyResponse + 17, // 51: iam_pb.SeaweedIdentityAccessManagement.DeleteAccessKey:output_type -> iam_pb.DeleteAccessKeyResponse + 19, // 52: iam_pb.SeaweedIdentityAccessManagement.GetUserByAccessKey:output_type -> iam_pb.GetUserByAccessKeyResponse + 35, // 53: iam_pb.SeaweedIdentityAccessManagement.PutPolicy:output_type -> iam_pb.PutPolicyResponse + 37, // 54: iam_pb.SeaweedIdentityAccessManagement.GetPolicy:output_type -> iam_pb.GetPolicyResponse + 39, // 55: iam_pb.SeaweedIdentityAccessManagement.ListPolicies:output_type -> iam_pb.ListPoliciesResponse + 41, // 56: iam_pb.SeaweedIdentityAccessManagement.DeletePolicy:output_type -> iam_pb.DeletePolicyResponse + 44, // 57: iam_pb.SeaweedIdentityAccessManagement.CreateServiceAccount:output_type -> iam_pb.CreateServiceAccountResponse + 46, // 58: iam_pb.SeaweedIdentityAccessManagement.UpdateServiceAccount:output_type -> iam_pb.UpdateServiceAccountResponse + 48, // 59: iam_pb.SeaweedIdentityAccessManagement.DeleteServiceAccount:output_type -> iam_pb.DeleteServiceAccountResponse + 50, // 60: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccount:output_type -> iam_pb.GetServiceAccountResponse + 52, // 61: iam_pb.SeaweedIdentityAccessManagement.ListServiceAccounts:output_type -> iam_pb.ListServiceAccountsResponse + 54, // 62: iam_pb.SeaweedIdentityAccessManagement.GetServiceAccountByAccessKey:output_type -> iam_pb.GetServiceAccountByAccessKeyResponse + 43, // [43:63] is the sub-list for method output_type + 23, // [23:43] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name } func init() { file_iam_proto_init() } @@ -3034,7 +3118,7 @@ func file_iam_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_iam_proto_rawDesc), len(file_iam_proto_rawDesc)), NumEnums: 0, - NumMessages: 58, + NumMessages: 59, NumExtensions: 0, NumServices: 1, }, From 6d8e36a4157d4c67d70a7c83a27e6ef9e48b5401 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:26:15 -0700 Subject: [PATCH 02/72] iam: add group CRUD to CredentialStore interface and all backends Add group management methods (CreateGroup, GetGroup, DeleteGroup, ListGroups, UpdateGroup) to the CredentialStore interface with implementations for memory, filer_etc, postgres, and grpc stores. Wire group loading/saving into filer_etc LoadConfiguration and SaveConfiguration. --- weed/credential/credential_store.go | 10 ++ weed/credential/filer_etc/filer_etc_group.go | 158 ++++++++++++++++++ .../filer_etc/filer_etc_identity.go | 43 +++++ weed/credential/grpc/grpc_group.go | 75 +++++++++ weed/credential/memory/memory_group.go | 62 +++++++ weed/credential/memory/memory_store.go | 4 + weed/credential/postgres/postgres_group.go | 116 +++++++++++++ weed/credential/postgres/postgres_store.go | 16 ++ 8 files changed, 484 insertions(+) create mode 100644 weed/credential/filer_etc/filer_etc_group.go create mode 100644 weed/credential/grpc/grpc_group.go create mode 100644 weed/credential/memory/memory_group.go create mode 100644 weed/credential/postgres/postgres_group.go diff --git a/weed/credential/credential_store.go b/weed/credential/credential_store.go index 0458677b9..f08f84540 100644 --- a/weed/credential/credential_store.go +++ b/weed/credential/credential_store.go @@ -18,6 +18,9 @@ var ( ErrPolicyNotFound = errors.New("policy not found") ErrPolicyAlreadyAttached = errors.New("policy already attached") ErrPolicyNotAttached = errors.New("policy not attached to user") + ErrGroupNotFound = errors.New("group not found") + ErrGroupAlreadyExists = errors.New("group already exists") + ErrUserNotInGroup = errors.New("user is not a member of the group") ) // CredentialStoreTypeName represents the type name of a credential store @@ -94,6 +97,13 @@ type CredentialStore interface { // ListAttachedUserPolicies returns the list of policy names attached to a user ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error) + // Group Management + CreateGroup(ctx context.Context, group *iam_pb.Group) error + GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) + DeleteGroup(ctx context.Context, groupName string) error + ListGroups(ctx context.Context) ([]string, error) + UpdateGroup(ctx context.Context, group *iam_pb.Group) error + // Shutdown performs cleanup when the store is being shut down Shutdown() } diff --git a/weed/credential/filer_etc/filer_etc_group.go b/weed/credential/filer_etc/filer_etc_group.go new file mode 100644 index 000000000..2a931bf22 --- /dev/null +++ b/weed/credential/filer_etc/filer_etc_group.go @@ -0,0 +1,158 @@ +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/glog" + "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 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 { + glog.Warningf("Failed to read group file %s: %v", entry.Name, err) + continue + } + content = c + } + + if len(content) > 0 { + g := &iam_pb.Group{} + if err := json.Unmarshal(content, g); err != nil { + glog.Warningf("Failed to unmarshal group %s: %v", entry.Name, err) + continue + } + 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 { + 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 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 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 _, err := store.GetGroup(ctx, group.Name); err != nil { + return err + } + return store.saveGroup(ctx, group) +} diff --git a/weed/credential/filer_etc/filer_etc_identity.go b/weed/credential/filer_etc/filer_etc_identity.go index 56af5381b..2f36247a7 100644 --- a/weed/credential/filer_etc/filer_etc_identity.go +++ b/weed/credential/filer_etc/filer_etc_identity.go @@ -45,6 +45,11 @@ func (store *FilerEtcStore) LoadConfiguration(ctx context.Context) (*iam_pb.S3Ap return s3cfg, fmt.Errorf("failed to load service accounts: %w", err) } + // 3b. Load groups + if err := store.loadGroupsFromMultiFile(ctx, s3cfg); err != nil { + return s3cfg, fmt.Errorf("failed to load groups: %w", err) + } + // 4. Perform migration if we loaded legacy config // This ensures that all identities (including legacy ones) are written to individual files // and the legacy file is renamed. @@ -171,6 +176,13 @@ func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_p } } + // 2b. Save all groups + for _, g := range config.Groups { + if err := store.saveGroup(ctx, g); err != nil { + return err + } + } + // 3. Cleanup removed identities (Full Sync) if err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { dir := filer.IamConfigDirectory + "/" + IamIdentitiesDirectory @@ -234,6 +246,37 @@ func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_p return err } + // 5. Cleanup removed groups (Full Sync) + if err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { + dir := filer.IamConfigDirectory + "/" + IamGroupsDirectory + entries, err := listEntries(ctx, client, dir) + if err != nil { + if err == filer_pb.ErrNotFound { + return nil + } + return err + } + + validNames := make(map[string]bool) + for _, g := range config.Groups { + validNames[g.Name+".json"] = true + } + + for _, entry := range entries { + if !entry.IsDirectory && !validNames[entry.Name] { + if _, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{ + Directory: dir, + Name: entry.Name, + }); err != nil { + glog.Warningf("Failed to delete obsolete group file %s: %v", entry.Name, err) + } + } + } + return nil + }); err != nil { + return err + } + return nil } diff --git a/weed/credential/grpc/grpc_group.go b/weed/credential/grpc/grpc_group.go new file mode 100644 index 000000000..9e2262dd7 --- /dev/null +++ b/weed/credential/grpc/grpc_group.go @@ -0,0 +1,75 @@ +package grpc + +import ( + "context" + + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" +) + +func (store *IamGrpcStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error { + 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 { + config, err := store.LoadConfiguration(ctx) + if err != nil { + return err + } + for i, g := range config.Groups { + if g.Name == group.Name { + config.Groups[i] = group + return store.SaveConfiguration(ctx, config) + } + } + return credential.ErrGroupNotFound +} diff --git a/weed/credential/memory/memory_group.go b/weed/credential/memory/memory_group.go new file mode 100644 index 000000000..6c5db9789 --- /dev/null +++ b/weed/credential/memory/memory_group.go @@ -0,0 +1,62 @@ +package memory + +import ( + "context" + + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" +) + +func (store *MemoryStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error { + store.mu.Lock() + defer store.mu.Unlock() + + if _, exists := store.groups[group.Name]; exists { + return credential.ErrGroupAlreadyExists + } + store.groups[group.Name] = 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 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 { + store.mu.Lock() + defer store.mu.Unlock() + + if _, exists := store.groups[group.Name]; !exists { + return credential.ErrGroupNotFound + } + store.groups[group.Name] = group + return nil +} diff --git a/weed/credential/memory/memory_store.go b/weed/credential/memory/memory_store.go index e92fdf94d..baca350a8 100644 --- a/weed/credential/memory/memory_store.go +++ b/weed/credential/memory/memory_store.go @@ -23,6 +23,7 @@ type MemoryStore struct { serviceAccounts map[string]*iam_pb.ServiceAccount // id -> service_account serviceAccountAccessKeys map[string]string // access_key -> id policies map[string]policy_engine.PolicyDocument // policy_name -> policy_document + groups map[string]*iam_pb.Group // group_name -> group initialized bool } @@ -43,6 +44,7 @@ func (store *MemoryStore) Initialize(configuration util.Configuration, prefix st store.serviceAccounts = make(map[string]*iam_pb.ServiceAccount) store.serviceAccountAccessKeys = make(map[string]string) store.policies = make(map[string]policy_engine.PolicyDocument) + store.groups = make(map[string]*iam_pb.Group) store.initialized = true return nil @@ -57,6 +59,7 @@ func (store *MemoryStore) Shutdown() { store.serviceAccounts = nil store.serviceAccountAccessKeys = nil store.policies = nil + store.groups = nil store.initialized = false } @@ -71,6 +74,7 @@ func (store *MemoryStore) Reset() { store.serviceAccounts = make(map[string]*iam_pb.ServiceAccount) store.serviceAccountAccessKeys = make(map[string]string) store.policies = make(map[string]policy_engine.PolicyDocument) + store.groups = make(map[string]*iam_pb.Group) } } diff --git a/weed/credential/postgres/postgres_group.go b/weed/credential/postgres/postgres_group.go new file mode 100644 index 000000000..9590c7a72 --- /dev/null +++ b/weed/credential/postgres/postgres_group.go @@ -0,0 +1,116 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "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 { + 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 { + // Check for unique constraint violation + 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 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 { + membersJSON, err := json.Marshal(group.Members) + if err != nil { + return fmt.Errorf("failed to marshal members: %w", err) + } + policyNamesJSON, err := json.Marshal(group.PolicyNames) + if err != nil { + return fmt.Errorf("failed to marshal policy_names: %w", err) + } + + result, err := store.db.ExecContext(ctx, + `UPDATE groups SET members = $1, policy_names = $2, disabled = $3, updated_at = CURRENT_TIMESTAMP WHERE name = $4`, + membersJSON, policyNamesJSON, group.Disabled, group.Name) + if err != nil { + return fmt.Errorf("failed to update group: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + if rows == 0 { + return credential.ErrGroupNotFound + } + return nil +} diff --git a/weed/credential/postgres/postgres_store.go b/weed/credential/postgres/postgres_store.go index 205e08ffa..13ac312f1 100644 --- a/weed/credential/postgres/postgres_store.go +++ b/weed/credential/postgres/postgres_store.go @@ -140,6 +140,18 @@ func (store *PostgresStore) createTables() error { ); ` + // Create groups table + groupsTable := ` + CREATE TABLE IF NOT EXISTS groups ( + name VARCHAR(255) PRIMARY KEY, + members JSONB DEFAULT '[]', + policy_names JSONB DEFAULT '[]', + disabled BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + ` + // Execute table creation if _, err := store.db.Exec(usersTable); err != nil { return fmt.Errorf("failed to create users table: %w", err) @@ -162,6 +174,10 @@ func (store *PostgresStore) createTables() error { return fmt.Errorf("failed to create service_accounts table: %w", err) } + if _, err := store.db.Exec(groupsTable); err != nil { + return fmt.Errorf("failed to create groups table: %w", err) + } + return nil } From b7c991688ea8baf5462acceb90428292df851c61 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:26:40 -0700 Subject: [PATCH 03/72] iam: add group IAM response types Add XML response types for group management IAM actions: CreateGroup, DeleteGroup, GetGroup, ListGroups, AddUserToGroup, RemoveUserFromGroup, AttachGroupPolicy, DetachGroupPolicy, ListAttachedGroupPolicies, ListGroupsForUser. --- weed/iam/responses.go | 80 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/weed/iam/responses.go b/weed/iam/responses.go index c64b3ce23..74e606bc7 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -280,3 +280,83 @@ type UpdateServiceAccountResponse struct { XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateServiceAccountResponse"` CommonResponse } + +// CreateGroupResponse is the response for CreateGroup action. +type CreateGroupResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateGroupResponse"` + CreateGroupResult struct { + Group iam.Group `xml:"Group"` + } `xml:"CreateGroupResult"` + CommonResponse +} + +// DeleteGroupResponse is the response for DeleteGroup action. +type DeleteGroupResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteGroupResponse"` + CommonResponse +} + +// GetGroupResponse is the response for GetGroup action. +type GetGroupResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetGroupResponse"` + GetGroupResult struct { + Group iam.Group `xml:"Group"` + Users []*iam.User `xml:"Users>member"` + IsTruncated bool `xml:"IsTruncated"` + } `xml:"GetGroupResult"` + CommonResponse +} + +// ListGroupsResponse is the response for ListGroups action. +type ListGroupsResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsResponse"` + ListGroupsResult struct { + Groups []*iam.Group `xml:"Groups>member"` + IsTruncated bool `xml:"IsTruncated"` + } `xml:"ListGroupsResult"` + CommonResponse +} + +// AddUserToGroupResponse is the response for AddUserToGroup action. +type AddUserToGroupResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AddUserToGroupResponse"` + CommonResponse +} + +// RemoveUserFromGroupResponse is the response for RemoveUserFromGroup action. +type RemoveUserFromGroupResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ RemoveUserFromGroupResponse"` + CommonResponse +} + +// AttachGroupPolicyResponse is the response for AttachGroupPolicy action. +type AttachGroupPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AttachGroupPolicyResponse"` + CommonResponse +} + +// DetachGroupPolicyResponse is the response for DetachGroupPolicy action. +type DetachGroupPolicyResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DetachGroupPolicyResponse"` + CommonResponse +} + +// ListAttachedGroupPoliciesResponse is the response for ListAttachedGroupPolicies action. +type ListAttachedGroupPoliciesResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListAttachedGroupPoliciesResponse"` + ListAttachedGroupPoliciesResult struct { + AttachedPolicies []*iam.AttachedPolicy `xml:"AttachedPolicies>member"` + IsTruncated bool `xml:"IsTruncated"` + } `xml:"ListAttachedGroupPoliciesResult"` + CommonResponse +} + +// ListGroupsForUserResponse is the response for ListGroupsForUser action. +type ListGroupsForUserResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListGroupsForUserResponse"` + ListGroupsForUserResult struct { + Groups []*iam.Group `xml:"Groups>member"` + IsTruncated bool `xml:"IsTruncated"` + } `xml:"ListGroupsForUserResult"` + CommonResponse +} From 38d504d1828968297f6c896a3ceb5248fd2c7527 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:28:11 -0700 Subject: [PATCH 04/72] iam: add group management handlers to embedded IAM API Add CreateGroup, DeleteGroup, GetGroup, ListGroups, AddUserToGroup, RemoveUserFromGroup, AttachGroupPolicy, DetachGroupPolicy, ListAttachedGroupPolicies, and ListGroupsForUser handlers with dispatch in ExecuteAction. --- weed/s3api/s3api_embedded_iam.go | 310 ++++++++++++++++++++++++++++++- 1 file changed, 309 insertions(+), 1 deletion(-) diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 5b65382be..a33d0c57e 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -113,6 +113,17 @@ type ( iamListServiceAccountsResponse = iamlib.ListServiceAccountsResponse iamGetServiceAccountResponse = iamlib.GetServiceAccountResponse iamUpdateServiceAccountResponse = iamlib.UpdateServiceAccountResponse + // Group response types + iamCreateGroupResponse = iamlib.CreateGroupResponse + iamDeleteGroupResponse = iamlib.DeleteGroupResponse + iamGetGroupResponse = iamlib.GetGroupResponse + iamListGroupsResponse = iamlib.ListGroupsResponse + iamAddUserToGroupResponse = iamlib.AddUserToGroupResponse + iamRemoveUserFromGroupResponse = iamlib.RemoveUserFromGroupResponse + iamAttachGroupPolicyResponse = iamlib.AttachGroupPolicyResponse + iamDetachGroupPolicyResponse = iamlib.DetachGroupPolicyResponse + iamListAttachedGroupPoliciesResponse = iamlib.ListAttachedGroupPoliciesResponse + iamListGroupsForUserResponse = iamlib.ListGroupsForUserResponse ) // Helper function wrappers using shared package @@ -1405,6 +1416,241 @@ func (e *EmbeddedIamApi) UpdateServiceAccount(s3cfg *iam_pb.S3ApiConfiguration, return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("service account %s not found", saId)} } +// Group Management Handlers + +func (e *EmbeddedIamApi) CreateGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamCreateGroupResponse, *iamError) { + resp := &iamCreateGroupResponse{} + groupName := values.Get("GroupName") + if groupName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + for _, g := range s3cfg.Groups { + if g.Name == groupName { + return resp, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("group %s already exists", groupName)} + } + } + s3cfg.Groups = append(s3cfg.Groups, &iam_pb.Group{Name: groupName}) + resp.CreateGroupResult.Group.GroupName = &groupName + return resp, nil +} + +func (e *EmbeddedIamApi) DeleteGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamDeleteGroupResponse, *iamError) { + resp := &iamDeleteGroupResponse{} + groupName := values.Get("GroupName") + if groupName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + for i, g := range s3cfg.Groups { + if g.Name == groupName { + if len(g.Members) > 0 { + return resp, &iamError{Code: iam.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete group %s: group has %d member(s). Remove all members first", groupName, len(g.Members))} + } + s3cfg.Groups = append(s3cfg.Groups[:i], s3cfg.Groups[i+1:]...) + return resp, nil + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} +} + +func (e *EmbeddedIamApi) GetGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamGetGroupResponse, *iamError) { + resp := &iamGetGroupResponse{} + groupName := values.Get("GroupName") + if groupName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + for _, g := range s3cfg.Groups { + if g.Name == groupName { + resp.GetGroupResult.Group.GroupName = &g.Name + for _, member := range g.Members { + memberName := member + resp.GetGroupResult.Users = append(resp.GetGroupResult.Users, &iam.User{UserName: &memberName}) + } + return resp, nil + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} +} + +func (e *EmbeddedIamApi) ListGroups(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) *iamListGroupsResponse { + resp := &iamListGroupsResponse{} + for _, g := range s3cfg.Groups { + name := g.Name + resp.ListGroupsResult.Groups = append(resp.ListGroupsResult.Groups, &iam.Group{GroupName: &name}) + } + return resp +} + +func (e *EmbeddedIamApi) AddUserToGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamAddUserToGroupResponse, *iamError) { + resp := &iamAddUserToGroupResponse{} + groupName := values.Get("GroupName") + userName := values.Get("UserName") + if groupName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + if userName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")} + } + // Verify user exists + userFound := false + for _, ident := range s3cfg.Identities { + if ident.Name == userName { + userFound = true + break + } + } + if !userFound { + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s does not exist", userName)} + } + for _, g := range s3cfg.Groups { + if g.Name == groupName { + // Check if already a member (idempotent) + for _, m := range g.Members { + if m == userName { + return resp, nil + } + } + g.Members = append(g.Members, userName) + return resp, nil + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} +} + +func (e *EmbeddedIamApi) RemoveUserFromGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamRemoveUserFromGroupResponse, *iamError) { + resp := &iamRemoveUserFromGroupResponse{} + groupName := values.Get("GroupName") + userName := values.Get("UserName") + if groupName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + if userName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")} + } + for _, g := range s3cfg.Groups { + if g.Name == groupName { + for i, m := range g.Members { + if m == userName { + g.Members = append(g.Members[:i], g.Members[i+1:]...) + return resp, nil + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s is not a member of group %s", userName, groupName)} + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} +} + +func (e *EmbeddedIamApi) AttachGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamAttachGroupPolicyResponse, *iamError) { + resp := &iamAttachGroupPolicyResponse{} + groupName := values.Get("GroupName") + policyArn := values.Get("PolicyArn") + if groupName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + policyName, err := iamPolicyNameFromArn(policyArn) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} + } + // Verify policy exists + policyFound := false + for _, p := range s3cfg.Policies { + if p.Name == policyName { + policyFound = true + break + } + } + if !policyFound { + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} + } + for _, g := range s3cfg.Groups { + if g.Name == groupName { + // Check if already attached (idempotent) + for _, p := range g.PolicyNames { + if p == policyName { + return resp, nil + } + } + g.PolicyNames = append(g.PolicyNames, policyName) + return resp, nil + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} +} + +func (e *EmbeddedIamApi) DetachGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamDetachGroupPolicyResponse, *iamError) { + resp := &iamDetachGroupPolicyResponse{} + groupName := values.Get("GroupName") + policyArn := values.Get("PolicyArn") + if groupName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + policyName, err := iamPolicyNameFromArn(policyArn) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} + } + for _, g := range s3cfg.Groups { + if g.Name == groupName { + for i, p := range g.PolicyNames { + if p == policyName { + g.PolicyNames = append(g.PolicyNames[:i], g.PolicyNames[i+1:]...) + return resp, nil + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s is not attached to group %s", policyName, groupName)} + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} +} + +func (e *EmbeddedIamApi) ListAttachedGroupPolicies(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamListAttachedGroupPoliciesResponse, *iamError) { + resp := &iamListAttachedGroupPoliciesResponse{} + groupName := values.Get("GroupName") + if groupName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + for _, g := range s3cfg.Groups { + if g.Name == groupName { + for _, policyName := range g.PolicyNames { + pn := policyName + resp.ListAttachedGroupPoliciesResult.AttachedPolicies = append(resp.ListAttachedGroupPoliciesResult.AttachedPolicies, &iam.AttachedPolicy{ + PolicyName: &pn, + }) + } + return resp, nil + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} +} + +func (e *EmbeddedIamApi) ListGroupsForUser(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamListGroupsForUserResponse, *iamError) { + resp := &iamListGroupsForUserResponse{} + userName := values.Get("UserName") + if userName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("UserName is required")} + } + // Verify user exists + userFound := false + for _, ident := range s3cfg.Identities { + if ident.Name == userName { + userFound = true + break + } + } + if !userFound { + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s does not exist", userName)} + } + for _, g := range s3cfg.Groups { + for _, m := range g.Members { + if m == userName { + name := g.Name + resp.ListGroupsForUserResult.Groups = append(resp.ListGroupsForUserResult.Groups, &iam.Group{GroupName: &name}) + break + } + } + } + return resp, nil +} + // handleImplicitUsername adds username who signs the request to values if 'username' is not specified. // According to AWS documentation: "If you do not specify a user name, IAM determines the user name // implicitly based on the Amazon Web Services access key ID signing the request." @@ -1558,7 +1804,8 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s action := values.Get("Action") if e.readOnly { switch action { - case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListPolicyVersions", "GetPolicyVersion", "ListServiceAccounts", "GetServiceAccount": + case "ListUsers", "ListAccessKeys", "GetUser", "GetUserPolicy", "ListAttachedUserPolicies", "ListPolicies", "GetPolicy", "ListPolicyVersions", "GetPolicyVersion", "ListServiceAccounts", "GetServiceAccount", + "GetGroup", "ListGroups", "ListAttachedGroupPolicies", "ListGroupsForUser": // Allowed read-only actions default: return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code, Error: fmt.Errorf("IAM write operations are disabled on this server")} @@ -1745,6 +1992,67 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s if iamErr != nil { return nil, iamErr } + // Group actions + case "CreateGroup": + var iamErr *iamError + response, iamErr = e.CreateGroup(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + case "DeleteGroup": + var iamErr *iamError + response, iamErr = e.DeleteGroup(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + case "GetGroup": + var iamErr *iamError + response, iamErr = e.GetGroup(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + changed = false + case "ListGroups": + response = e.ListGroups(s3cfg, values) + changed = false + case "AddUserToGroup": + var iamErr *iamError + response, iamErr = e.AddUserToGroup(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + case "RemoveUserFromGroup": + var iamErr *iamError + response, iamErr = e.RemoveUserFromGroup(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + case "AttachGroupPolicy": + var iamErr *iamError + response, iamErr = e.AttachGroupPolicy(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + case "DetachGroupPolicy": + var iamErr *iamError + response, iamErr = e.DetachGroupPolicy(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + case "ListAttachedGroupPolicies": + var iamErr *iamError + response, iamErr = e.ListAttachedGroupPolicies(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + changed = false + case "ListGroupsForUser": + var iamErr *iamError + response, iamErr = e.ListGroupsForUser(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } + changed = false default: return nil, &iamError{Code: s3err.GetAPIError(s3err.ErrNotImplemented).Code, Error: errors.New(s3err.GetAPIError(s3err.ErrNotImplemented).Description)} } From 204512151bad5c34986f611d4051fb39169e4f46 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:29:58 -0700 Subject: [PATCH 05/72] iam: add group management handlers to standalone IAM API Add group handlers (CreateGroup, DeleteGroup, GetGroup, ListGroups, AddUserToGroup, RemoveUserFromGroup, AttachGroupPolicy, DetachGroupPolicy, ListAttachedGroupPolicies, ListGroupsForUser) and wire into DoActions dispatch. Also add helper functions for user/policy side effects. --- weed/iamapi/iamapi_group_handlers.go | 282 ++++++++++++++++++++++ weed/iamapi/iamapi_management_handlers.go | 70 ++++++ weed/iamapi/iamapi_response.go | 11 + 3 files changed, 363 insertions(+) create mode 100644 weed/iamapi/iamapi_group_handlers.go diff --git a/weed/iamapi/iamapi_group_handlers.go b/weed/iamapi/iamapi_group_handlers.go new file mode 100644 index 000000000..0af42880b --- /dev/null +++ b/weed/iamapi/iamapi_group_handlers.go @@ -0,0 +1,282 @@ +package iamapi + +import ( + "fmt" + "net/url" + "strings" + + "github.com/aws/aws-sdk-go/service/iam" + "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))} + } + 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) 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 + policyFound := false + for _, p := range s3cfg.Policies { + if p.Name == policyName { + policyFound = true + break + } + } + if !policyFound { + 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)} + } + for _, g := range s3cfg.Groups { + for _, m := range g.Members { + if m == userName { + name := g.Name + resp.ListGroupsForUserResult.Groups = append(resp.ListGroupsForUserResult.Groups, &iam.Group{GroupName: &name}) + break + } + } + } + return resp, nil +} + +// removeUserFromAllGroups removes a user from all groups they belong to. +func removeUserFromAllGroups(s3cfg *iam_pb.S3ApiConfiguration, userName string) { + for _, g := range s3cfg.Groups { + 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 +} + +// policyNameFromArn extracts policy name from ARN for standalone handlers. +func policyNameFromArn(policyArn string) string { + return strings.TrimPrefix(policyArn, policyArnPrefix) +} diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index c2141e6cd..78c580821 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -1055,6 +1055,76 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) { return } changed = false + // Group actions + case "CreateGroup": + var err *IamError + response, err = iama.CreateGroup(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + case "DeleteGroup": + var err *IamError + response, err = iama.DeleteGroup(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + case "GetGroup": + var err *IamError + response, err = iama.GetGroup(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + changed = false + case "ListGroups": + response = iama.ListGroups(s3cfg, values) + changed = false + case "AddUserToGroup": + var err *IamError + response, err = iama.AddUserToGroup(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + case "RemoveUserFromGroup": + var err *IamError + response, err = iama.RemoveUserFromGroup(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + case "AttachGroupPolicy": + var err *IamError + response, err = iama.AttachGroupPolicy(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + case "DetachGroupPolicy": + var err *IamError + response, err = iama.DetachGroupPolicy(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + case "ListAttachedGroupPolicies": + var err *IamError + response, err = iama.ListAttachedGroupPolicies(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + changed = false + case "ListGroupsForUser": + var err *IamError + response, err = iama.ListGroupsForUser(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } + changed = false default: errNotImplemented := s3err.GetAPIError(s3err.ErrNotImplemented) errorResponse := newErrorResponse(errNotImplemented.Code, errNotImplemented.Description, reqID) diff --git a/weed/iamapi/iamapi_response.go b/weed/iamapi/iamapi_response.go index ea596655d..a8dafb50c 100644 --- a/weed/iamapi/iamapi_response.go +++ b/weed/iamapi/iamapi_response.go @@ -36,4 +36,15 @@ type ( ListServiceAccountsResponse = iamlib.ListServiceAccountsResponse GetServiceAccountResponse = iamlib.GetServiceAccountResponse UpdateServiceAccountResponse = iamlib.UpdateServiceAccountResponse + // Group response types + CreateGroupResponse = iamlib.CreateGroupResponse + DeleteGroupResponse = iamlib.DeleteGroupResponse + GetGroupResponse = iamlib.GetGroupResponse + ListGroupsResponse = iamlib.ListGroupsResponse + AddUserToGroupResponse = iamlib.AddUserToGroupResponse + RemoveUserFromGroupResponse = iamlib.RemoveUserFromGroupResponse + AttachGroupPolicyResponse = iamlib.AttachGroupPolicyResponse + DetachGroupPolicyResponse = iamlib.DetachGroupPolicyResponse + ListAttachedGroupPoliciesResponse = iamlib.ListAttachedGroupPoliciesResponse + ListGroupsForUserResponse = iamlib.ListGroupsForUserResponse ) From e660180438a516b0145737092f647a9c8b3660cc Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:31:40 -0700 Subject: [PATCH 06/72] iam: integrate group policies into authorization Add groups and userGroups reverse index to IdentityAccessManagement. Populate both maps during ReplaceS3ApiConfiguration and MergeS3ApiConfiguration. Modify evaluateIAMPolicies to evaluate policies from user's enabled groups in addition to user policies. Update VerifyActionPermission to consider group policies when checking hasAttachedPolicies. --- weed/s3api/auth_credentials.go | 80 ++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index ae7b48be3..e412008f0 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -49,6 +49,8 @@ type IdentityAccessManagement struct { accessKeyIdent map[string]*Identity nameToIdentity map[string]*Identity // O(1) lookup by identity name policies map[string]*iam_pb.Policy + groups map[string]*iam_pb.Group // group name -> group + userGroups map[string][]string // user name -> group names (reverse index) accounts map[string]*Account emailAccount map[string]*Account hashes map[string]*sync.Pool @@ -563,6 +565,16 @@ func (iam *IdentityAccessManagement) ReplaceS3ApiConfiguration(config *iam_pb.S3 for _, policy := range config.Policies { policies[policy.Name] = policy } + groups := make(map[string]*iam_pb.Group) + userGroupsMap := make(map[string][]string) + for _, g := range config.Groups { + groups[g.Name] = g + if !g.Disabled { + for _, member := range g.Members { + userGroupsMap[member] = append(userGroupsMap[member], g.Name) + } + } + } for _, ident := range config.Identities { glog.V(3).Infof("loading identity %s (disabled=%v)", ident.Name, ident.Disabled) t := &Identity{ @@ -663,6 +675,8 @@ func (iam *IdentityAccessManagement) ReplaceS3ApiConfiguration(config *iam_pb.S3 iam.nameToIdentity = nameToIdentity iam.accessKeyIdent = accessKeyIdent iam.policies = policies + iam.groups = groups + iam.userGroups = userGroupsMap iam.rebuildIAMPolicyEngineLocked() // Re-add environment-based identities that were preserved @@ -911,6 +925,18 @@ func (iam *IdentityAccessManagement) MergeS3ApiConfiguration(config *iam_pb.S3Ap policies[policy.Name] = policy } + // Process groups from dynamic config + mergedGroups := make(map[string]*iam_pb.Group) + mergedUserGroups := make(map[string][]string) + for _, g := range config.Groups { + mergedGroups[g.Name] = g + if !g.Disabled { + for _, member := range g.Members { + mergedUserGroups[member] = append(mergedUserGroups[member], g.Name) + } + } + } + iam.m.Lock() // atomically switch iam.identities = identities @@ -920,6 +946,8 @@ func (iam *IdentityAccessManagement) MergeS3ApiConfiguration(config *iam_pb.S3Ap iam.nameToIdentity = nameToIdentity iam.accessKeyIdent = accessKeyIdent iam.policies = policies + iam.groups = mergedGroups + iam.userGroups = mergedUserGroups iam.rebuildIAMPolicyEngineLocked() // Update authentication state based on whether identities exist // Once enabled, keep it enabled (one-way toggle) @@ -1837,14 +1865,18 @@ func determineIAMAuthPath(sessionToken, principal, principalArn string) iamAuthP // Returns true if any matching statement explicitly allows the action. // Uses the cached iamPolicyEngine to avoid re-parsing policy JSON on every request. func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identity *Identity, action Action, bucket, object string) bool { - if identity == nil || len(identity.PolicyNames) == 0 { - return false - } - iam.m.RLock() engine := iam.iamPolicyEngine + groupNames := iam.userGroups[identity.Name] + groupMap := iam.groups iam.m.RUnlock() + // Collect all policy names: user policies + group policies + hasPolicies := len(identity.PolicyNames) > 0 || len(groupNames) > 0 + if identity == nil || !hasPolicies { + return false + } + if engine == nil { return false } @@ -1858,15 +1890,17 @@ func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identi conditions[k] = v } - for _, policyName := range identity.PolicyNames { - result := engine.EvaluatePolicy(policyName, &policy_engine.PolicyEvaluationArgs{ - Action: s3Action, - Resource: resource, - Principal: principal, - Conditions: conditions, - Claims: identity.Claims, - }) + evalArgs := &policy_engine.PolicyEvaluationArgs{ + Action: s3Action, + Resource: resource, + Principal: principal, + Conditions: conditions, + Claims: identity.Claims, + } + // Evaluate user's own policies + for _, policyName := range identity.PolicyNames { + result := engine.EvaluatePolicy(policyName, evalArgs) if result == policy_engine.PolicyResultDeny { return false } @@ -1875,6 +1909,23 @@ func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identi } } + // Evaluate policies from user's groups (skip disabled groups) + for _, gName := range groupNames { + g, ok := groupMap[gName] + if !ok || g.Disabled { + continue + } + for _, policyName := range g.PolicyNames { + result := engine.EvaluatePolicy(policyName, evalArgs) + if result == policy_engine.PolicyResultDeny { + return false + } + if result == policy_engine.PolicyResultAllow { + explicitAllow = true + } + } + } + return explicitAllow } @@ -1894,7 +1945,10 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide hasSessionToken := r.Header.Get("X-SeaweedFS-Session-Token") != "" || r.Header.Get("X-Amz-Security-Token") != "" || r.URL.Query().Get("X-Amz-Security-Token") != "" - hasAttachedPolicies := len(identity.PolicyNames) > 0 + iam.m.RLock() + userGroupNames := iam.userGroups[identity.Name] + iam.m.RUnlock() + hasAttachedPolicies := len(identity.PolicyNames) > 0 || len(userGroupNames) > 0 if (len(identity.Actions) == 0 || hasSessionToken || hasAttachedPolicies) && iam.iamIntegration != nil { return iam.authorizeWithIAM(r, identity, action, bucket, object) From ccf0057ea8f715cfa1e82d3e546271c8f659cc91 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:32:59 -0700 Subject: [PATCH 07/72] iam: add group side effects on user deletion and rename When a user is deleted, remove them from all groups they belong to. When a user is renamed, update group membership references. Applied to both embedded and standalone IAM handlers. --- weed/iamapi/iamapi_management_handlers.go | 4 ++++ weed/s3api/s3api_embedded_iam.go | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index 78c580821..5449a5d9f 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -219,6 +219,8 @@ func (iama *IamApiServer) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName } } s3cfg.Identities = append(s3cfg.Identities[:i], s3cfg.Identities[i+1:]...) + // Remove user from all groups + removeUserFromAllGroups(s3cfg, userName) return resp, nil } } @@ -258,6 +260,8 @@ func (iama *IamApiServer) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values ur } } } + // Update group membership references + updateUserInGroups(s3cfg, userName, newUserName) return resp, nil } } diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index a33d0c57e..320d8e22e 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -315,6 +315,15 @@ func (e *EmbeddedIamApi) DeleteUser(s3cfg *iam_pb.S3ApiConfiguration, userName s } } s3cfg.Identities = append(s3cfg.Identities[:i], s3cfg.Identities[i+1:]...) + // Remove user from all groups + for _, g := range s3cfg.Groups { + for j, m := range g.Members { + if m == userName { + g.Members = append(g.Members[:j], g.Members[j+1:]...) + break + } + } + } return resp, nil } } @@ -342,6 +351,15 @@ func (e *EmbeddedIamApi) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values url for _, ident := range s3cfg.Identities { if userName == ident.Name { ident.Name = newUserName + // Update group membership references + for _, g := range s3cfg.Groups { + for j, m := range g.Members { + if m == userName { + g.Members[j] = newUserName + break + } + } + } return resp, nil } } From 05c51d1d5be2648539f2111dc6249cec7ef0c133 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:33:21 -0700 Subject: [PATCH 08/72] iam: watch /etc/iam/groups directory for config changes Add groups directory to the filer subscription watcher so group file changes trigger IAM configuration reloads. --- weed/s3api/auth_credentials_subscribe.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/weed/s3api/auth_credentials_subscribe.go b/weed/s3api/auth_credentials_subscribe.go index 30aad4fcb..e491ee6a1 100644 --- a/weed/s3api/auth_credentials_subscribe.go +++ b/weed/s3api/auth_credentials_subscribe.go @@ -93,8 +93,9 @@ func (s3a *S3ApiServer) onIamConfigChange(dir string, oldEntry *filer_pb.Entry, isIdentityDir := dir == filer.IamConfigDirectory+"/identities" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/identities/") isPolicyDir := dir == filer.IamConfigDirectory+"/policies" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/policies/") isServiceAccountDir := dir == filer.IamConfigDirectory+"/service_accounts" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/service_accounts/") + isGroupDir := dir == filer.IamConfigDirectory+"/groups" || strings.HasPrefix(dir, filer.IamConfigDirectory+"/groups/") - if isIdentityDir || isPolicyDir || isServiceAccountDir { + if isIdentityDir || isPolicyDir || isServiceAccountDir || isGroupDir { // For multiple-file mode, any change in these directories should trigger a full reload // from the credential manager (which handles the details of loading from multiple files). if err := reloadIamConfig(dir); err != nil { From 75b8d7c8216dbc63dcdb0852ec2f8a046d2e3e52 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:41:28 -0700 Subject: [PATCH 09/72] admin: add group management page to admin UI Add groups page with CRUD operations, member management, policy attachment, and enable/disable toggle. Register routes in admin handlers and add Groups entry to sidebar navigation. --- weed/admin/dash/group_management.go | 206 +++++++++++++ weed/admin/dash/types.go | 24 ++ weed/admin/handlers/admin_handlers.go | 17 ++ weed/admin/handlers/group_handlers.go | 235 +++++++++++++++ weed/admin/static/js/iam-utils.js | 23 ++ weed/admin/view/app/groups.templ | 396 +++++++++++++++++++++++++ weed/admin/view/app/groups_templ.go | 256 ++++++++++++++++ weed/admin/view/layout/layout.templ | 5 + weed/admin/view/layout/layout_templ.go | 30 +- weed/credential/credential_manager.go | 22 ++ 10 files changed, 1199 insertions(+), 15 deletions(-) create mode 100644 weed/admin/dash/group_management.go create mode 100644 weed/admin/handlers/group_handlers.go create mode 100644 weed/admin/view/app/groups.templ create mode 100644 weed/admin/view/app/groups_templ.go diff --git a/weed/admin/dash/group_management.go b/weed/admin/dash/group_management.go new file mode 100644 index 000000000..26e519e84 --- /dev/null +++ b/weed/admin/dash/group_management.go @@ -0,0 +1,206 @@ +package dash + +import ( + "context" + "fmt" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" +) + +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", name) + return &GroupData{ + Name: 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") + } + 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) + } + 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) + } + 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", username, groupName) + } + 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) + } + 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) + } + 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", policyName, groupName) + } + 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.Disabled = !enabled + if err := s.credentialManager.UpdateGroup(ctx, g); err != nil { + return fmt.Errorf("failed to update group: %w", err) + } + glog.V(1).Infof("Set group %s status to enabled=%v", groupName, enabled) + return nil +} diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go index 4dbdc965c..965166de4 100644 --- a/weed/admin/dash/types.go +++ b/weed/admin/dash/types.go @@ -589,6 +589,30 @@ type UpdateServiceAccountRequest struct { Expiration string `json:"expiration,omitempty"` } +// Group management structures +type GroupData struct { + Name string `json:"name"` + MemberCount int `json:"member_count"` + PolicyCount int `json:"policy_count"` + Status string `json:"status"` // "enabled" or "disabled" + Members []string `json:"members"` + PolicyNames []string `json:"policy_names"` +} + +type GroupsPageData struct { + Username string `json:"username"` + Groups []GroupData `json:"groups"` + TotalGroups int `json:"total_groups"` + ActiveGroups int `json:"active_groups"` + AvailableUsers []string `json:"available_users"` + AvailablePolicies []string `json:"available_policies"` + LastUpdated time.Time `json:"last_updated"` +} + +type CreateGroupRequest struct { + Name string `json:"name"` +} + // STS Configuration display types type STSConfigData struct { Enabled bool `json:"enabled"` diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index ff0d8651a..38938c25b 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -28,6 +28,7 @@ type AdminHandlers struct { pluginHandlers *PluginHandlers mqHandlers *MessageQueueHandlers serviceAccountHandlers *ServiceAccountHandlers + groupHandlers *GroupHandlers } // NewAdminHandlers creates a new instance of AdminHandlers @@ -40,6 +41,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *Admi pluginHandlers := NewPluginHandlers(adminServer) mqHandlers := NewMessageQueueHandlers(adminServer) serviceAccountHandlers := NewServiceAccountHandlers(adminServer) + groupHandlers := NewGroupHandlers(adminServer) return &AdminHandlers{ adminServer: adminServer, sessionStore: store, @@ -51,6 +53,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *Admi pluginHandlers: pluginHandlers, mqHandlers: mqHandlers, serviceAccountHandlers: serviceAccountHandlers, + groupHandlers: groupHandlers, } } @@ -104,6 +107,7 @@ func (h *AdminHandlers) registerUIRoutes(r *mux.Router) { r.HandleFunc("/object-store/buckets/{bucket}", h.ShowBucketDetails).Methods(http.MethodGet) r.HandleFunc("/object-store/users", h.userHandlers.ShowObjectStoreUsers).Methods(http.MethodGet) r.HandleFunc("/object-store/policies", h.policyHandlers.ShowPolicies).Methods(http.MethodGet) + r.HandleFunc("/object-store/groups", h.groupHandlers.ShowGroups).Methods(http.MethodGet) r.HandleFunc("/object-store/service-accounts", h.serviceAccountHandlers.ShowServiceAccounts).Methods(http.MethodGet) r.HandleFunc("/object-store/s3tables/buckets", h.ShowS3TablesBuckets).Methods(http.MethodGet) r.HandleFunc("/object-store/s3tables/buckets/{bucket}/namespaces", h.ShowS3TablesNamespaces).Methods(http.MethodGet) @@ -185,6 +189,19 @@ func (h *AdminHandlers) registerAPIRoutes(api *mux.Router, enforceWrite bool) { saApi.Handle("/{id}", wrapWrite(h.serviceAccountHandlers.UpdateServiceAccount)).Methods(http.MethodPut) saApi.Handle("/{id}", wrapWrite(h.serviceAccountHandlers.DeleteServiceAccount)).Methods(http.MethodDelete) + groupsApi := api.PathPrefix("/groups").Subrouter() + groupsApi.HandleFunc("", h.groupHandlers.GetGroups).Methods(http.MethodGet) + groupsApi.Handle("", wrapWrite(h.groupHandlers.CreateGroup)).Methods(http.MethodPost) + groupsApi.HandleFunc("/{name}", h.groupHandlers.GetGroupDetails).Methods(http.MethodGet) + groupsApi.Handle("/{name}", wrapWrite(h.groupHandlers.DeleteGroup)).Methods(http.MethodDelete) + groupsApi.Handle("/{name}/status", wrapWrite(h.groupHandlers.SetGroupStatus)).Methods(http.MethodPut) + groupsApi.HandleFunc("/{name}/members", h.groupHandlers.GetGroupMembers).Methods(http.MethodGet) + groupsApi.Handle("/{name}/members", wrapWrite(h.groupHandlers.AddGroupMember)).Methods(http.MethodPost) + groupsApi.Handle("/{name}/members/{username}", wrapWrite(h.groupHandlers.RemoveGroupMember)).Methods(http.MethodDelete) + groupsApi.HandleFunc("/{name}/policies", h.groupHandlers.GetGroupPolicies).Methods(http.MethodGet) + groupsApi.Handle("/{name}/policies", wrapWrite(h.groupHandlers.AttachGroupPolicy)).Methods(http.MethodPost) + groupsApi.Handle("/{name}/policies/{policyName}", wrapWrite(h.groupHandlers.DetachGroupPolicy)).Methods(http.MethodDelete) + policyApi := api.PathPrefix("/object-store/policies").Subrouter() policyApi.HandleFunc("", h.policyHandlers.GetPolicies).Methods(http.MethodGet) policyApi.Handle("", wrapWrite(h.policyHandlers.CreatePolicy)).Methods(http.MethodPost) diff --git a/weed/admin/handlers/group_handlers.go b/weed/admin/handlers/group_handlers.go new file mode 100644 index 000000000..721072c53 --- /dev/null +++ b/weed/admin/handlers/group_handlers.go @@ -0,0 +1,235 @@ +package handlers + +import ( + "bytes" + "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/glog" +) + +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, http.StatusInternalServerError, "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) + writeJSONError(w, http.StatusNotFound, "Group not found") + 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, http.StatusInternalServerError, "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, http.StatusNotFound, "Group not found") + 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, http.StatusInternalServerError, "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, http.StatusInternalServerError, "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, http.StatusNotFound, "Group not found") + 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, http.StatusInternalServerError, "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, http.StatusInternalServerError, "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 err := h.adminServer.SetGroupStatus(r.Context(), name, req.Enabled); err != nil { + writeJSONError(w, http.StatusInternalServerError, "Failed to update group status: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": "Group status updated"}) +} + +func (h *GroupHandlers) getGroupsPageData(r *http.Request) dash.GroupsPageData { + username := dash.UsernameFromContext(r.Context()) + if username == "" { + username = "admin" + } + + groups, err := h.adminServer.GetGroups(r.Context()) + if err != nil { + glog.Errorf("Failed to get groups: %v", err) + return dash.GroupsPageData{ + Username: username, + Groups: []dash.GroupData{}, + LastUpdated: time.Now(), + } + } + + activeCount := 0 + for _, g := range groups { + if g.Status == "enabled" { + activeCount++ + } + } + + // Get available users for dropdown + var availableUsers []string + users, err := h.adminServer.GetObjectStoreUsers(r.Context()) + if err == nil { + for _, user := range users { + availableUsers = append(availableUsers, user.Username) + } + } + + // Get available policies for dropdown + var availablePolicies []string + policies, err := h.adminServer.GetPolicies() + if err == nil { + for _, p := range policies { + availablePolicies = append(availablePolicies, p.Name) + } + } + + return dash.GroupsPageData{ + Username: username, + Groups: groups, + TotalGroups: len(groups), + ActiveGroups: activeCount, + AvailableUsers: availableUsers, + AvailablePolicies: availablePolicies, + LastUpdated: time.Now(), + } +} diff --git a/weed/admin/static/js/iam-utils.js b/weed/admin/static/js/iam-utils.js index baf8ba457..1b50d54a6 100644 --- a/weed/admin/static/js/iam-utils.js +++ b/weed/admin/static/js/iam-utils.js @@ -25,6 +25,29 @@ async function deleteUser(username) { }, 'Are you sure you want to delete this user? This action cannot be undone.'); } +// Delete group function +async function deleteGroup(name) { + showDeleteConfirm(name, async function () { + try { + const encodedName = encodeURIComponent(name); + const response = await fetch(`/api/groups/${encodedName}`, { + method: 'DELETE' + }); + + if (response.ok) { + showAlert('Group deleted successfully', 'success'); + setTimeout(() => window.location.reload(), 1000); + } else { + const error = await response.json().catch(() => ({})); + showAlert('Failed to delete group: ' + (error.error || 'Unknown error'), 'error'); + } + } catch (error) { + console.error('Error deleting group:', error); + showAlert('Failed to delete group: ' + error.message, 'error'); + } + }, 'Are you sure you want to delete this group? This action cannot be undone.'); +} + // Delete access key function async function deleteAccessKey(username, accessKey) { showDeleteConfirm(accessKey, async function () { diff --git a/weed/admin/view/app/groups.templ b/weed/admin/view/app/groups.templ new file mode 100644 index 000000000..6fb8b0be9 --- /dev/null +++ b/weed/admin/view/app/groups.templ @@ -0,0 +1,396 @@ +package app + +import ( + "fmt" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" +) + +templ Groups(data dash.GroupsPageData) { +
+ +
+
+

+ Groups +

+

Manage IAM groups for organizing users and policies

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

No groups found. Create a group to get started.

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

Groups

Manage IAM groups for organizing users and policies

Total Groups
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalGroups)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/groups.templ`, Line: 38, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Active Groups
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.ActiveGroups)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/groups.templ`, Line: 57, Col: 73} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
Groups
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.Groups) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

No groups found. Create a group to get started.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, group := range data.Groups { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
NameMembersPoliciesStatusActions
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(group.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/groups.templ`, Line: 96, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", group.MemberCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/groups.templ`, Line: 99, Col: 109} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", group.PolicyCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/groups.templ`, Line: 102, Col: 114} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if group.Status == "enabled" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "Enabled") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Disabled") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("viewGroup('%s')", group.Name)}) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, templ.ComponentScript{Call: fmt.Sprintf("deleteGroup('%s')", group.Name)}) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Create Group
Group Details
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ index 6e87b462a..d717548fa 100644 --- a/weed/admin/view/layout/layout.templ +++ b/weed/admin/view/layout/layout.templ @@ -168,6 +168,11 @@ templ Layout(view ViewContext, content templ.Component) { Users +
OBJECT STORE
MANAGEMENT
OBJECT STORE
MANAGEMENT
  • File Browser
  • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -344,7 +344,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year())) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 331, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 336, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -357,7 +357,7 @@ func Layout(view ViewContext, content templ.Component) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 331, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 336, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -409,7 +409,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 359, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 364, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -422,7 +422,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 373, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 378, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -440,7 +440,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 380, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 385, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -458,7 +458,7 @@ func LoginForm(title string, errorMessage string, csrfToken string) templ.Compon var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(csrfToken) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 385, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/layout/layout.templ`, Line: 390, Col: 84} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { diff --git a/weed/credential/credential_manager.go b/weed/credential/credential_manager.go index e78cbf4c9..d9df17e50 100644 --- a/weed/credential/credential_manager.go +++ b/weed/credential/credential_manager.go @@ -236,3 +236,25 @@ func (cm *CredentialManager) DetachUserPolicy(ctx context.Context, username stri func (cm *CredentialManager) ListAttachedUserPolicies(ctx context.Context, username string) ([]string, error) { return cm.Store.ListAttachedUserPolicies(ctx, username) } + +// Group Management + +func (cm *CredentialManager) CreateGroup(ctx context.Context, group *iam_pb.Group) error { + return cm.Store.CreateGroup(ctx, group) +} + +func (cm *CredentialManager) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) { + return cm.Store.GetGroup(ctx, groupName) +} + +func (cm *CredentialManager) DeleteGroup(ctx context.Context, groupName string) error { + return cm.Store.DeleteGroup(ctx, groupName) +} + +func (cm *CredentialManager) ListGroups(ctx context.Context) ([]string, error) { + return cm.Store.ListGroups(ctx) +} + +func (cm *CredentialManager) UpdateGroup(ctx context.Context, group *iam_pb.Group) error { + return cm.Store.UpdateGroup(ctx, group) +} From 930252532d1805c382b1e277a79e18534a82efe5 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:47:28 -0700 Subject: [PATCH 10/72] test: add IAM group management integration tests Add comprehensive integration tests for group CRUD, membership, policy attachment, policy enforcement, disabled group behavior, user deletion side effects, and multi-group membership. Add "group" test type to CI matrix in s3-iam-tests workflow. --- .github/workflows/s3-iam-tests.yml | 9 +- test/s3/iam/Makefile | 5 +- test/s3/iam/s3_iam_group_test.go | 693 +++++++++++++++++++++++++++++ 3 files changed, 705 insertions(+), 2 deletions(-) create mode 100644 test/s3/iam/s3_iam_group_test.go diff --git a/.github/workflows/s3-iam-tests.yml b/.github/workflows/s3-iam-tests.yml index 841d32514..04e402377 100644 --- a/.github/workflows/s3-iam-tests.yml +++ b/.github/workflows/s3-iam-tests.yml @@ -5,6 +5,7 @@ on: paths: - 'weed/iam/**' - 'weed/s3api/**' + - 'weed/credential/**' - 'test/s3/iam/**' - '.github/workflows/s3-iam-tests.yml' push: @@ -12,6 +13,7 @@ on: paths: - 'weed/iam/**' - 'weed/s3api/**' + - 'weed/credential/**' - 'test/s3/iam/**' - '.github/workflows/s3-iam-tests.yml' @@ -80,7 +82,7 @@ jobs: timeout-minutes: 25 strategy: matrix: - test-type: ["basic", "advanced", "policy-enforcement"] + test-type: ["basic", "advanced", "policy-enforcement", "group"] steps: - name: Check out code @@ -129,6 +131,11 @@ jobs: make clean setup start-services wait-for-services go test -v -timeout 15m -run "TestS3IAMPolicyEnforcement|TestS3IAMBucketPolicy|TestS3IAMContextual" ./... ;; + "group") + echo "Running IAM group management tests..." + make clean setup start-services wait-for-services + go test -v -timeout 15m -run "TestIAMGroup" ./... + ;; *) echo "Unknown test type: ${{ matrix.test-type }}" exit 1 diff --git a/test/s3/iam/Makefile b/test/s3/iam/Makefile index 6eb5b0db8..6dbb54299 100644 --- a/test/s3/iam/Makefile +++ b/test/s3/iam/Makefile @@ -185,6 +185,9 @@ test-context: ## Test only contextual policy enforcement test-presigned: ## Test only presigned URL integration go test -v -run TestS3IAMPresignedURLIntegration ./... +test-group: ## Run IAM group management tests + go test -v -run "TestIAMGroup" ./... + test-sts: ## Run all STS tests go test -v -run "TestSTS" ./... @@ -263,7 +266,7 @@ docker-build: ## Build custom SeaweedFS image for Docker tests # All PHONY targets .PHONY: test test-quick run-tests setup start-services stop-services wait-for-services clean logs status debug -.PHONY: test-auth test-policy test-expiration test-multipart test-bucket-policy test-context test-presigned test-sts test-sts-assume-role test-sts-ldap +.PHONY: test-auth test-policy test-expiration test-multipart test-bucket-policy test-context test-presigned test-group test-sts test-sts-assume-role test-sts-ldap .PHONY: benchmark ci watch install-deps docker-test docker-up docker-down docker-logs docker-build .PHONY: test-distributed test-performance test-stress test-versioning-stress test-keycloak-full test-all-previously-skipped setup-all-tests help-advanced diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go new file mode 100644 index 000000000..82c03e877 --- /dev/null +++ b/test/s3/iam/s3_iam_group_test.go @@ -0,0 +1,693 @@ +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) + defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) + + createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + policyArn := createPolicyResp.Policy.Arn + defer iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}) + + 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) + defer func() { + iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: aws.String(groupName), + UserName: aws.String(userName), + }) + iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + }() + + // Create access key for the user + keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{ + UserName: aws.String(userName), + }) + require.NoError(t, err) + defer iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + UserName: aws.String(userName), + AccessKeyId: keyResp.AccessKey.AccessKeyId, + }) + + 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) + defer func() { + iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: aws.String("arn:aws:iam:::policy/" + policyName), + }) + iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) + }() + + // Create policy + createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), + PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + policyArn := createPolicyResp.Policy.Arn + defer iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}) + + 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 + time.Sleep(2 * time.Second) + + // Now user should be able to create the bucket through group policy + _, err = userS3Client.CreateBucket(&s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + }) + require.NoError(t, err, "User with group policy should be allowed") + defer func() { + userS3Client.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) + }() + + // 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 + time.Sleep(2 * time.Second) + + // User should now be denied + _, err = userS3Client.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(bucketName), + }) + assert.Error(t, err, "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) + defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + + keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(userName)}) + require.NoError(t, err) + defer iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + UserName: aws.String(userName), AccessKeyId: keyResp.AccessKey.AccessKeyId, + }) + + _, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(groupName)}) + require.NoError(t, err) + defer func() { + iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: aws.String("arn:aws:iam:::policy/" + policyName), + }) + iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: aws.String(groupName), UserName: aws.String(userName), + }) + iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) + }() + + createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ + PolicyName: aws.String(policyName), PolicyDocument: aws.String(policyDoc), + }) + require.NoError(t, err) + defer iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: createPolicyResp.Policy.Arn}) + + // 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) + + time.Sleep(2 * time.Second) + + 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) { + _, err := userS3Client.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(bucketName), + }) + assert.NoError(t, err, "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 := callIAMAPI(t, "UpdateGroup", url.Values{ + "GroupName": {groupName}, + "Disabled": {"true"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + // Wait for propagation + time.Sleep(2 * time.Second) + + _, err = userS3Client.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(bucketName), + }) + assert.Error(t, err, "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 := callIAMAPI(t, "UpdateGroup", url.Values{ + "GroupName": {groupName}, + "Disabled": {"false"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + + // Wait for propagation + time.Sleep(2 * time.Second) + + _, err = userS3Client.ListObjects(&s3.ListObjectsInput{ + Bucket: aws.String(bucketName), + }) + assert.NoError(t, err, "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) + + // 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"` +} + +// TestIAMGroupRawAPI tests group operations using raw HTTP IAM API calls, +// for operations not covered by the AWS SDK (like the SeaweedFS extension +// to disable/enable groups via UpdateGroup with Disabled parameter). +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) + } + + groupName := "test-raw-api-group" + + t.Run("create_group_raw", func(t *testing.T) { + resp, err := callIAMAPI(t, "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 := callIAMAPI(t, "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 := callIAMAPI(t, "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) +} From 07f6734e8002f0e244c9b5c8e61be2d239a6265c Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 14:55:13 -0700 Subject: [PATCH 11/72] iam: address PR review comments for group management - Fix XSS vulnerability in groups.templ: replace innerHTML string concatenation with DOM APIs (createElement/textContent) for rendering member and policy lists - Use userGroups reverse index in embedded IAM ListGroupsForUser for O(1) lookup instead of iterating all groups - Add buildUserGroupsIndex helper in standalone IAM handlers; use it in ListGroupsForUser and removeUserFromAllGroups for efficient lookup - Add note about gRPC store load-modify-save race condition limitation --- weed/admin/view/app/groups.templ | 54 +++++++++++++++++++++------- weed/admin/view/app/groups_templ.go | 2 +- weed/credential/grpc/grpc_group.go | 5 +++ weed/iamapi/iamapi_group_handlers.go | 38 +++++++++++++++----- weed/s3api/s3api_embedded_iam.go | 15 ++++---- 5 files changed, 85 insertions(+), 29 deletions(-) diff --git a/weed/admin/view/app/groups.templ b/weed/admin/view/app/groups.templ index 6fb8b0be9..f6d5ad7bc 100644 --- a/weed/admin/view/app/groups.templ +++ b/weed/admin/view/app/groups.templ @@ -267,29 +267,59 @@ templ Groups(data dash.GroupsPageData) { if (!response.ok) throw new Error('Failed to fetch group'); const group = await response.json(); - // Render members - let membersHtml = ''; + // 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) { - membersHtml += ''; + 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 = ''; + td2.appendChild(btn); } } else { - membersHtml += ''; + const tr = membersTbody.insertRow(); + const td = tr.insertCell(); + td.className = 'text-muted'; + td.textContent = 'No members'; } - membersHtml += '
    ' + member + '
    No members
    '; - document.getElementById('membersList').innerHTML = membersHtml; + membersTable.appendChild(membersTbody); + membersList.appendChild(membersTable); - // Render policies - let policiesHtml = ''; + // 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) { - policiesHtml += ''; + 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 = ''; + td2.appendChild(btn); } } else { - policiesHtml += ''; + const tr = policiesTbody.insertRow(); + const td = tr.insertCell(); + td.className = 'text-muted'; + td.textContent = 'No policies attached'; } - policiesHtml += '
    ' + policy + '
    No policies attached
    '; - document.getElementById('policiesList').innerHTML = policiesHtml; + policiesTable.appendChild(policiesTbody); + policiesList.appendChild(policiesTable); // Update status toggle document.getElementById('groupEnabledSwitch').checked = (group.status === 'enabled'); diff --git a/weed/admin/view/app/groups_templ.go b/weed/admin/view/app/groups_templ.go index 0b417f4bb..ec131521b 100644 --- a/weed/admin/view/app/groups_templ.go +++ b/weed/admin/view/app/groups_templ.go @@ -245,7 +245,7 @@ func Groups(data dash.GroupsPageData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/credential/grpc/grpc_group.go b/weed/credential/grpc/grpc_group.go index 9e2262dd7..394dc13ca 100644 --- a/weed/credential/grpc/grpc_group.go +++ b/weed/credential/grpc/grpc_group.go @@ -7,6 +7,11 @@ import ( "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 { config, err := store.LoadConfiguration(ctx) if err != nil { diff --git a/weed/iamapi/iamapi_group_handlers.go b/weed/iamapi/iamapi_group_handlers.go index 0af42880b..dc4071d94 100644 --- a/weed/iamapi/iamapi_group_handlers.go +++ b/weed/iamapi/iamapi_group_handlers.go @@ -228,21 +228,32 @@ func (iama *IamApiServer) ListGroupsForUser(s3cfg *iam_pb.S3ApiConfiguration, va if !userFound { return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s does not exist", userName)} } - for _, g := range s3cfg.Groups { - for _, m := range g.Members { - if m == userName { - name := g.Name - resp.ListGroupsForUserResult.Groups = append(resp.ListGroupsForUserResult.Groups, &iam.Group{GroupName: &name}) - break - } - } + // 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:]...) @@ -276,6 +287,17 @@ func isPolicyAttachedToAnyGroup(s3cfg *iam_pb.S3ApiConfiguration, policyName str 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 +} + // policyNameFromArn extracts policy name from ARN for standalone handlers. func policyNameFromArn(policyArn string) string { return strings.TrimPrefix(policyArn, policyArnPrefix) diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 320d8e22e..8ea95b666 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -1657,14 +1657,13 @@ func (e *EmbeddedIamApi) ListGroupsForUser(s3cfg *iam_pb.S3ApiConfiguration, val if !userFound { return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s does not exist", userName)} } - for _, g := range s3cfg.Groups { - for _, m := range g.Members { - if m == userName { - name := g.Name - resp.ListGroupsForUserResult.Groups = append(resp.ListGroupsForUserResult.Groups, &iam.Group{GroupName: &name}) - break - } - } + // Use the in-memory reverse index for O(1) lookup + e.iam.m.RLock() + groupNames := e.iam.userGroups[userName] + e.iam.m.RUnlock() + for _, gName := range groupNames { + name := gName + resp.ListGroupsForUserResult.Groups = append(resp.ListGroupsForUserResult.Groups, &iam.Group{GroupName: &name}) } return resp, nil } From e367600b2143a2e95330c0567f2efa0c70c10839 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:21:44 -0700 Subject: [PATCH 12/72] iam: add defensive copies, validation, and XSS fixes for group management - Memory store: clone groups on store/retrieve to prevent mutation - Admin dash: deep copy groups before mutation, validate user/policy exists - HTTP handlers: translate credential errors to proper HTTP status codes, use *bool for Enabled field to distinguish missing vs false - Groups templ: use data attributes + event delegation instead of inline onclick for XSS safety, prevent stale async responses --- weed/admin/dash/group_management.go | 28 ++++++++++++++++++ weed/admin/handlers/group_handlers.go | 39 +++++++++++++++++++------- weed/admin/view/app/groups.templ | 31 ++++++++++++++------ weed/credential/memory/memory_group.go | 26 +++++++++++++++-- 4 files changed, 102 insertions(+), 22 deletions(-) diff --git a/weed/admin/dash/group_management.go b/weed/admin/dash/group_management.go index 26e519e84..43f70615e 100644 --- a/weed/admin/dash/group_management.go +++ b/weed/admin/dash/group_management.go @@ -8,6 +8,23 @@ import ( "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") @@ -99,6 +116,10 @@ func (s *AdminServer) AddGroupMember(ctx context.Context, groupName, username st 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 @@ -120,6 +141,7 @@ func (s *AdminServer) RemoveGroupMember(ctx context.Context, groupName, username 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 { @@ -148,6 +170,10 @@ func (s *AdminServer) AttachGroupPolicy(ctx context.Context, groupName, policyNa 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 @@ -169,6 +195,7 @@ func (s *AdminServer) DetachGroupPolicy(ctx context.Context, groupName, policyNa 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 { @@ -197,6 +224,7 @@ func (s *AdminServer) SetGroupStatus(ctx context.Context, groupName string, enab 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) diff --git a/weed/admin/handlers/group_handlers.go b/weed/admin/handlers/group_handlers.go index 721072c53..ee625decc 100644 --- a/weed/admin/handlers/group_handlers.go +++ b/weed/admin/handlers/group_handlers.go @@ -2,6 +2,7 @@ package handlers import ( "bytes" + "errors" "net/http" "time" @@ -9,9 +10,23 @@ import ( "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 + } + return http.StatusInternalServerError +} + type GroupHandlers struct { adminServer *dash.AdminServer } @@ -59,7 +74,7 @@ func (h *GroupHandlers) CreateGroup(w http.ResponseWriter, r *http.Request) { group, err := h.adminServer.CreateGroup(r.Context(), req.Name) if err != nil { glog.Errorf("Failed to create group: %v", err) - writeJSONError(w, http.StatusInternalServerError, "Failed to create group: "+err.Error()) + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to create group: "+err.Error()) return } writeJSON(w, http.StatusOK, group) @@ -70,7 +85,7 @@ func (h *GroupHandlers) GetGroupDetails(w http.ResponseWriter, r *http.Request) group, err := h.adminServer.GetGroupDetails(r.Context(), name) if err != nil { glog.Errorf("Failed to get group details: %v", err) - writeJSONError(w, http.StatusNotFound, "Group not found") + writeJSONError(w, groupErrorToHTTPStatus(err), "Group not found") return } writeJSON(w, http.StatusOK, group) @@ -80,7 +95,7 @@ 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, http.StatusInternalServerError, "Failed to delete group: "+err.Error()) + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to delete group: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "Group deleted successfully"}) @@ -110,7 +125,7 @@ func (h *GroupHandlers) AddGroupMember(w http.ResponseWriter, r *http.Request) { return } if err := h.adminServer.AddGroupMember(r.Context(), name, req.Username); err != nil { - writeJSONError(w, http.StatusInternalServerError, "Failed to add member: "+err.Error()) + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to add member: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "Member added successfully"}) @@ -120,7 +135,7 @@ 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, http.StatusInternalServerError, "Failed to remove member: "+err.Error()) + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to remove member: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "Member removed successfully"}) @@ -150,7 +165,7 @@ func (h *GroupHandlers) AttachGroupPolicy(w http.ResponseWriter, r *http.Request return } if err := h.adminServer.AttachGroupPolicy(r.Context(), name, req.PolicyName); err != nil { - writeJSONError(w, http.StatusInternalServerError, "Failed to attach policy: "+err.Error()) + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to attach policy: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "Policy attached successfully"}) @@ -160,7 +175,7 @@ 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, http.StatusInternalServerError, "Failed to detach policy: "+err.Error()) + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to detach policy: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "Policy detached successfully"}) @@ -169,14 +184,18 @@ func (h *GroupHandlers) DetachGroupPolicy(w http.ResponseWriter, r *http.Request func (h *GroupHandlers) SetGroupStatus(w http.ResponseWriter, r *http.Request) { name := mux.Vars(r)["name"] var req struct { - Enabled bool `json:"enabled"` + Enabled *bool `json:"enabled"` } if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil { writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error()) return } - if err := h.adminServer.SetGroupStatus(r.Context(), name, req.Enabled); err != nil { - writeJSONError(w, http.StatusInternalServerError, "Failed to update group status: "+err.Error()) + 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"}) diff --git a/weed/admin/view/app/groups.templ b/weed/admin/view/app/groups.templ index f6d5ad7bc..fd729106a 100644 --- a/weed/admin/view/app/groups.templ +++ b/weed/admin/view/app/groups.templ @@ -110,11 +110,13 @@ templ Groups(data dash.GroupsPageData) { @@ -257,14 +259,15 @@ templ Groups(data dash.GroupsPageData) { async function viewGroup(name) { currentGroupName = name; document.getElementById('viewGroupTitle').textContent = 'Group: ' + name; - await refreshGroupDetails(); + await refreshGroupDetails(name); new bootstrap.Modal(document.getElementById('viewGroupModal')).show(); } - async function refreshGroupDetails() { + async function refreshGroupDetails(requestedName) { try { - const response = await fetch('/api/groups/' + encodeURIComponent(currentGroupName)); + 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 @@ -338,7 +341,7 @@ templ Groups(data dash.GroupsPageData) { body: JSON.stringify({ username: username }) }); if (response.ok) { - await refreshGroupDetails(); + await refreshGroupDetails(currentGroupName); showAlert('Member added', 'success'); } else { const error = await response.json().catch(() => ({})); @@ -355,7 +358,7 @@ templ Groups(data dash.GroupsPageData) { method: 'DELETE' }); if (response.ok) { - await refreshGroupDetails(); + await refreshGroupDetails(currentGroupName); showAlert('Member removed', 'success'); } else { const error = await response.json().catch(() => ({})); @@ -376,7 +379,7 @@ templ Groups(data dash.GroupsPageData) { body: JSON.stringify({ policy_name: policyName }) }); if (response.ok) { - await refreshGroupDetails(); + await refreshGroupDetails(currentGroupName); showAlert('Policy attached', 'success'); } else { const error = await response.json().catch(() => ({})); @@ -393,7 +396,7 @@ templ Groups(data dash.GroupsPageData) { method: 'DELETE' }); if (response.ok) { - await refreshGroupDetails(); + await refreshGroupDetails(currentGroupName); showAlert('Policy detached', 'success'); } else { const error = await response.json().catch(() => ({})); @@ -422,5 +425,15 @@ templ Groups(data dash.GroupsPageData) { 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); + }); } diff --git a/weed/credential/memory/memory_group.go b/weed/credential/memory/memory_group.go index 6c5db9789..180ce51c5 100644 --- a/weed/credential/memory/memory_group.go +++ b/weed/credential/memory/memory_group.go @@ -7,6 +7,26 @@ import ( "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 { store.mu.Lock() defer store.mu.Unlock() @@ -14,7 +34,7 @@ func (store *MemoryStore) CreateGroup(ctx context.Context, group *iam_pb.Group) if _, exists := store.groups[group.Name]; exists { return credential.ErrGroupAlreadyExists } - store.groups[group.Name] = group + store.groups[group.Name] = cloneGroup(group) return nil } @@ -23,7 +43,7 @@ func (store *MemoryStore) GetGroup(ctx context.Context, groupName string) (*iam_ defer store.mu.RUnlock() if g, exists := store.groups[groupName]; exists { - return g, nil + return cloneGroup(g), nil } return nil, credential.ErrGroupNotFound } @@ -57,6 +77,6 @@ func (store *MemoryStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) if _, exists := store.groups[group.Name]; !exists { return credential.ErrGroupNotFound } - store.groups[group.Name] = group + store.groups[group.Name] = cloneGroup(group) return nil } From 2f7289d99978f202d9762e42b1154c5707c1d544 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:26:40 -0700 Subject: [PATCH 13/72] iam: add explicit group methods to PropagatingCredentialStore Add CreateGroup, GetGroup, DeleteGroup, ListGroups, and UpdateGroup methods instead of relying on embedded interface fallthrough. Group changes propagate via filer subscription so no RPC propagation needed. --- weed/credential/propagating_store.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/weed/credential/propagating_store.go b/weed/credential/propagating_store.go index d8fe615c9..d43ec6746 100644 --- a/weed/credential/propagating_store.go +++ b/weed/credential/propagating_store.go @@ -385,3 +385,26 @@ func (s *PropagatingCredentialStore) DeleteServiceAccount(ctx context.Context, i }) return nil } + +func (s *PropagatingCredentialStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error { + glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateGroup %s", group.Name) + return s.CredentialStore.CreateGroup(ctx, group) +} + +func (s *PropagatingCredentialStore) GetGroup(ctx context.Context, groupName string) (*iam_pb.Group, error) { + return s.CredentialStore.GetGroup(ctx, groupName) +} + +func (s *PropagatingCredentialStore) DeleteGroup(ctx context.Context, groupName string) error { + glog.V(4).Infof("IAM: PropagatingCredentialStore.DeleteGroup %s", groupName) + return s.CredentialStore.DeleteGroup(ctx, groupName) +} + +func (s *PropagatingCredentialStore) ListGroups(ctx context.Context) ([]string, error) { + return s.CredentialStore.ListGroups(ctx) +} + +func (s *PropagatingCredentialStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error { + glog.V(4).Infof("IAM: PropagatingCredentialStore.UpdateGroup %s", group.Name) + return s.CredentialStore.UpdateGroup(ctx, group) +} From 1487ecbc8e6e2adeee3baf8f227615e83a32c863 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:26:45 -0700 Subject: [PATCH 14/72] iam: detect postgres unique constraint violation and add groups index Return ErrGroupAlreadyExists when INSERT hits SQLState 23505 instead of a generic error. Add index on groups(disabled) for filtered queries. --- weed/credential/postgres/postgres_group.go | 7 ++++++- weed/credential/postgres/postgres_store.go | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/weed/credential/postgres/postgres_group.go b/weed/credential/postgres/postgres_group.go index 9590c7a72..12850f641 100644 --- a/weed/credential/postgres/postgres_group.go +++ b/weed/credential/postgres/postgres_group.go @@ -4,8 +4,10 @@ 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" ) @@ -24,7 +26,10 @@ func (store *PostgresStore) CreateGroup(ctx context.Context, group *iam_pb.Group `INSERT INTO groups (name, members, policy_names, disabled) VALUES ($1, $2, $3, $4)`, group.Name, membersJSON, policyNamesJSON, group.Disabled) if err != nil { - // Check for unique constraint violation + 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 diff --git a/weed/credential/postgres/postgres_store.go b/weed/credential/postgres/postgres_store.go index 13ac312f1..4c9a9771c 100644 --- a/weed/credential/postgres/postgres_store.go +++ b/weed/credential/postgres/postgres_store.go @@ -178,6 +178,12 @@ func (store *PostgresStore) createTables() error { return fmt.Errorf("failed to create groups table: %w", err) } + // Create index on groups disabled column for filtering + groupsDisabledIndex := `CREATE INDEX IF NOT EXISTS idx_groups_disabled ON groups (disabled);` + if _, err := store.db.Exec(groupsDisabledIndex); err != nil { + return fmt.Errorf("failed to create groups disabled index: %w", err) + } + return nil } From 79da12eee938745caecf84f71d2f1cecccfbd239 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:26:49 -0700 Subject: [PATCH 15/72] iam: add Marker field to group list response types Add Marker string field to GetGroupResult, ListGroupsResult, ListAttachedGroupPoliciesResult, and ListGroupsForUserResult to match AWS IAM pagination response format. --- weed/iam/responses.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/weed/iam/responses.go b/weed/iam/responses.go index 74e606bc7..4757e7852 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -300,9 +300,10 @@ type DeleteGroupResponse struct { type GetGroupResponse struct { XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetGroupResponse"` GetGroupResult struct { - Group iam.Group `xml:"Group"` - Users []*iam.User `xml:"Users>member"` - IsTruncated bool `xml:"IsTruncated"` + Group iam.Group `xml:"Group"` + Users []*iam.User `xml:"Users>member"` + IsTruncated bool `xml:"IsTruncated"` + Marker string `xml:"Marker"` } `xml:"GetGroupResult"` CommonResponse } @@ -313,6 +314,7 @@ type ListGroupsResponse struct { ListGroupsResult struct { Groups []*iam.Group `xml:"Groups>member"` IsTruncated bool `xml:"IsTruncated"` + Marker string `xml:"Marker"` } `xml:"ListGroupsResult"` CommonResponse } @@ -347,6 +349,7 @@ type ListAttachedGroupPoliciesResponse struct { ListAttachedGroupPoliciesResult struct { AttachedPolicies []*iam.AttachedPolicy `xml:"AttachedPolicies>member"` IsTruncated bool `xml:"IsTruncated"` + Marker string `xml:"Marker"` } `xml:"ListAttachedGroupPoliciesResult"` CommonResponse } @@ -357,6 +360,7 @@ type ListGroupsForUserResponse struct { ListGroupsForUserResult struct { Groups []*iam.Group `xml:"Groups>member"` IsTruncated bool `xml:"IsTruncated"` + Marker string `xml:"Marker"` } `xml:"ListGroupsForUserResult"` CommonResponse } From 886019f467abaf798ade47a8e4e26fac1dfae131 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:26:55 -0700 Subject: [PATCH 16/72] iam: check group attachment before policy deletion Reject DeletePolicy if the policy is attached to any group, matching AWS IAM behavior. Add PolicyArn to ListAttachedGroupPolicies response. --- weed/iamapi/iamapi_management_handlers.go | 8 ++++++++ weed/s3api/s3api_embedded_iam.go | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index 5449a5d9f..a8babd24e 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -544,6 +544,14 @@ func (iama *IamApiServer) DeletePolicy(s3cfg *iam_pb.S3ApiConfiguration, values } } + // Reject deletion if the policy is attached to any group + if groupName, attached := isPolicyAttachedToAnyGroup(s3cfg, policyName); attached { + return resp, &IamError{ + Code: iam.ErrCodeDeleteConflictException, + Error: fmt.Errorf("policy %s is still attached to group %s", policyName, groupName), + } + } + delete(policies.Policies, policyName) if err := iama.s3ApiConfig.PutPolicies(&policies); err != nil { return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: err} diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 8ea95b666..f6ee985e5 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -510,6 +510,25 @@ func (e *EmbeddedIamApi) DeletePolicy(ctx context.Context, values url.Values) (* } } } + // Check if policy is attached to any group + groupNames, err := e.credentialManager.ListGroups(ctx) + if err != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} + } + for _, gn := range groupNames { + g, err := e.credentialManager.GetGroup(ctx, gn) + if err != nil { + continue + } + for _, pn := range g.PolicyNames { + if pn == policyName { + return resp, &iamError{ + Code: iam.ErrCodeDeleteConflictException, + Error: fmt.Errorf("policy %s is attached to group %s", policyName, gn), + } + } + } + } if err := e.credentialManager.DeletePolicy(ctx, policyName); err != nil { return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: err} } @@ -1630,8 +1649,10 @@ func (e *EmbeddedIamApi) ListAttachedGroupPolicies(s3cfg *iam_pb.S3ApiConfigurat if g.Name == groupName { for _, policyName := range g.PolicyNames { pn := policyName + policyArn := fmt.Sprintf("arn:aws:iam:::policy/%s", policyName) resp.ListAttachedGroupPoliciesResult.AttachedPolicies = append(resp.ListAttachedGroupPoliciesResult.AttachedPolicies, &iam.AttachedPolicy{ PolicyName: &pn, + PolicyArn: &policyArn, }) } return resp, nil From d80e7af8f065854d6fe680c2aaea66203ec8976e Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:27:01 -0700 Subject: [PATCH 17/72] iam: include group policies in IAM authorization Merge policy names from user's enabled groups into the IAMIdentity used for authorization, so group-attached policies are evaluated alongside user-attached policies. --- weed/s3api/auth_credentials.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index e412008f0..d6db8b859 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -1996,11 +1996,25 @@ func (iam *IdentityAccessManagement) authorizeWithIAM(r *http.Request, identity } } - // Create IAMIdentity for authorization + // Create IAMIdentity for authorization — copy PolicyNames to avoid mutating shared identity + policyNames := make([]string, len(identity.PolicyNames)) + copy(policyNames, identity.PolicyNames) + + // Include policies inherited from user's groups + iam.m.RLock() + if groupNames, ok := iam.userGroups[identity.Name]; ok { + for _, gn := range groupNames { + if g, exists := iam.groups[gn]; exists && !g.Disabled { + policyNames = append(policyNames, g.PolicyNames...) + } + } + } + iam.m.RUnlock() + iamIdentity := &IAMIdentity{ Name: identity.Name, Account: identity.Account, - PolicyNames: identity.PolicyNames, + PolicyNames: policyNames, Claims: identity.Claims, // Copy claims for policy variable substitution } From c84e2f56af327e76d779279a105a02c105afe4be Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:34:50 -0700 Subject: [PATCH 18/72] iam: check for name collision before renaming user in UpdateUser Scan identities and inline policies for newUserName before mutating, returning EntityAlreadyExists if a collision is found. Reuse the already-loaded policies instead of loading them again inside the loop. --- weed/iamapi/iamapi_management_handlers.go | 27 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index a8babd24e..fabe25db8 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -243,14 +243,33 @@ func (iama *IamApiServer) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values ur userName := values.Get("UserName") newUserName := values.Get("NewUserName") if newUserName != "" { + // Check for name collision before renaming + for _, ident := range s3cfg.Identities { + if ident.Name == newUserName { + return resp, &IamError{ + Code: iam.ErrCodeEntityAlreadyExistsException, + Error: fmt.Errorf("user %s already exists", newUserName), + } + } + } + // Check for inline policy collision + policies := Policies{} + if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} + } + if policies.InlinePolicies != nil { + if _, exists := policies.InlinePolicies[newUserName]; exists { + return resp, &IamError{ + Code: iam.ErrCodeEntityAlreadyExistsException, + Error: fmt.Errorf("inline policies already exist for user %s", newUserName), + } + } + } + for _, ident := range s3cfg.Identities { if userName == ident.Name { ident.Name = newUserName // Move any inline policies from old username to new username - policies := Policies{} - if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) { - return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} - } if policies.InlinePolicies != nil { if userPolicies, exists := policies.InlinePolicies[userName]; exists { delete(policies.InlinePolicies, userName) From 8089e9bb5b8a9d1389ca2cdf0c49616eb4accf9e Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:34:54 -0700 Subject: [PATCH 19/72] test: use t.Cleanup for bucket cleanup in group policy test --- test/s3/iam/s3_iam_group_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go index 82c03e877..d8784278f 100644 --- a/test/s3/iam/s3_iam_group_test.go +++ b/test/s3/iam/s3_iam_group_test.go @@ -346,9 +346,9 @@ func TestIAMGroupPolicyEnforcement(t *testing.T) { Bucket: aws.String(bucketName), }) require.NoError(t, err, "User with group policy should be allowed") - defer func() { + t.Cleanup(func() { userS3Client.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) - }() + }) // Should also be able to put/get objects _, err = userS3Client.PutObject(&s3.PutObjectInput{ From ff9a1fdbaa1f6576ba21a3f130fa327a45274717 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:58:05 -0700 Subject: [PATCH 20/72] iam: wrap ErrUserNotInGroup sentinel in RemoveGroupMember error Wrap credential.ErrUserNotInGroup so errors.Is works in groupErrorToHTTPStatus, returning proper 400 instead of 500. --- weed/admin/dash/group_management.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/weed/admin/dash/group_management.go b/weed/admin/dash/group_management.go index 43f70615e..a06973faa 100644 --- a/weed/admin/dash/group_management.go +++ b/weed/admin/dash/group_management.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" ) @@ -152,7 +153,7 @@ func (s *AdminServer) RemoveGroupMember(ctx context.Context, groupName, username } } if !found { - return fmt.Errorf("user %s is not a member of group %s", username, groupName) + 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 { From 519ea3c03bb0c64f6181461ac382f0b3184128a6 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:58:10 -0700 Subject: [PATCH 21/72] admin: regenerate groups_templ.go with XSS-safe data attributes Regenerated from groups.templ which uses data-group-name attributes instead of inline onclick with string interpolation. --- weed/admin/view/app/groups_templ.go | 58 +++++++++++++---------------- 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/weed/admin/view/app/groups_templ.go b/weed/admin/view/app/groups_templ.go index ec131521b..ae9e3fbdb 100644 --- a/weed/admin/view/app/groups_templ.go +++ b/weed/admin/view/app/groups_templ.go @@ -129,123 +129,115 @@ func Groups(data dash.GroupsPageData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" data-action=\"view\"> ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" data-action=\"delete\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
    Create Group
    Group Details
    Group Details
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From 5012a1b7160acbd278dead8166fb206abbad5614 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:58:14 -0700 Subject: [PATCH 22/72] iam: add input validation and persist groups during migration - Validate nil/empty group name in CreateGroup and UpdateGroup - Save groups in migrateToMultiFile so they survive legacy migration --- weed/credential/filer_etc/filer_etc_group.go | 6 ++++++ weed/credential/filer_etc/filer_etc_identity.go | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/weed/credential/filer_etc/filer_etc_group.go b/weed/credential/filer_etc/filer_etc_group.go index 2a931bf22..04ae00b73 100644 --- a/weed/credential/filer_etc/filer_etc_group.go +++ b/weed/credential/filer_etc/filer_etc_group.go @@ -93,6 +93,9 @@ func (store *FilerEtcStore) deleteGroupFile(ctx context.Context, groupName strin } func (store *FilerEtcStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error { + 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) { @@ -151,6 +154,9 @@ func (store *FilerEtcStore) ListGroups(ctx context.Context) ([]string, error) { } func (store *FilerEtcStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error { + if group == nil || group.Name == "" { + return fmt.Errorf("group name is required") + } if _, err := store.GetGroup(ctx, group.Name); err != nil { return err } diff --git a/weed/credential/filer_etc/filer_etc_identity.go b/weed/credential/filer_etc/filer_etc_identity.go index 2f36247a7..4046a934a 100644 --- a/weed/credential/filer_etc/filer_etc_identity.go +++ b/weed/credential/filer_etc/filer_etc_identity.go @@ -149,7 +149,14 @@ func (store *FilerEtcStore) migrateToMultiFile(ctx context.Context, s3cfg *iam_p } } - // 3. Rename legacy file + // 3. Save all groups + for _, g := range s3cfg.Groups { + if err := store.saveGroup(ctx, g); err != nil { + return err + } + } + + // 4. Rename legacy file return store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { _, err := client.AtomicRenameEntry(ctx, &filer_pb.AtomicRenameEntryRequest{ OldDirectory: filer.IamConfigDirectory, From 5b77cc7a264e27cf2cb9e0a86d0c5a808aea9345 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:58:18 -0700 Subject: [PATCH 23/72] admin: use groupErrorToHTTPStatus in GetGroupMembers and GetGroupPolicies --- weed/admin/handlers/group_handlers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/weed/admin/handlers/group_handlers.go b/weed/admin/handlers/group_handlers.go index ee625decc..0539352d2 100644 --- a/weed/admin/handlers/group_handlers.go +++ b/weed/admin/handlers/group_handlers.go @@ -105,7 +105,7 @@ 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, http.StatusNotFound, "Group not found") + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to get group: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]interface{}{"members": group.Members}) @@ -145,7 +145,7 @@ 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, http.StatusNotFound, "Group not found") + writeJSONError(w, groupErrorToHTTPStatus(err), "Failed to get group: "+err.Error()) return } writeJSON(w, http.StatusOK, map[string]interface{}{"policies": group.PolicyNames}) From f687001b9ee6ff2bd1fa0564272cf898168caf9d Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:58:22 -0700 Subject: [PATCH 24/72] iam: short-circuit UpdateUser when newUserName equals current name --- weed/iamapi/iamapi_management_handlers.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index fabe25db8..8b9438014 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -243,6 +243,10 @@ func (iama *IamApiServer) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values ur userName := values.Get("UserName") newUserName := values.Get("NewUserName") if newUserName != "" { + // No-op if renaming to the same name + if newUserName == userName { + return resp, nil + } // Check for name collision before renaming for _, ident := range s3cfg.Identities { if ident.Name == newUserName { From b0bca50dfdd8aa63d95e57a579bd3fcf14a2c3ad Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:58:27 -0700 Subject: [PATCH 25/72] iam: require empty PolicyNames before group deletion Reject DeleteGroup when group has attached policies, matching the existing members check. Also fix GetGroup error handling in DeletePolicy to only skip ErrGroupNotFound, not all errors. --- weed/iamapi/iamapi_group_handlers.go | 3 +++ weed/s3api/s3api_embedded_iam.go | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/weed/iamapi/iamapi_group_handlers.go b/weed/iamapi/iamapi_group_handlers.go index dc4071d94..49d386e0c 100644 --- a/weed/iamapi/iamapi_group_handlers.go +++ b/weed/iamapi/iamapi_group_handlers.go @@ -36,6 +36,9 @@ func (iama *IamApiServer) DeleteGroup(s3cfg *iam_pb.S3ApiConfiguration, values u 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 } diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index f6ee985e5..e89b6c39b 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -518,7 +518,10 @@ func (e *EmbeddedIamApi) DeletePolicy(ctx context.Context, values url.Values) (* for _, gn := range groupNames { g, err := e.credentialManager.GetGroup(ctx, gn) if err != nil { - continue + if errors.Is(err, credential.ErrGroupNotFound) { + continue + } + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to get group %s: %w", gn, err)} } for _, pn := range g.PolicyNames { if pn == policyName { @@ -1482,6 +1485,9 @@ func (e *EmbeddedIamApi) DeleteGroup(s3cfg *iam_pb.S3ApiConfiguration, values ur if len(g.Members) > 0 { return resp, &iamError{Code: iam.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete group %s: group has %d member(s). Remove all members first", groupName, len(g.Members))} } + if len(g.PolicyNames) > 0 { + return resp, &iamError{Code: iam.ErrCodeDeleteConflictException, Error: fmt.Errorf("cannot delete group %s: group has %d attached policy(ies). Detach all policies first", groupName, len(g.PolicyNames))} + } s3cfg.Groups = append(s3cfg.Groups[:i], s3cfg.Groups[i+1:]...) return resp, nil } From ef1b2c3cb2bdf7b4755b3446d405fb70ccb1d4d6 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:58:31 -0700 Subject: [PATCH 26/72] ci: add weed/pb/** to S3 IAM test trigger paths --- .github/workflows/s3-iam-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/s3-iam-tests.yml b/.github/workflows/s3-iam-tests.yml index 04e402377..39eebdee5 100644 --- a/.github/workflows/s3-iam-tests.yml +++ b/.github/workflows/s3-iam-tests.yml @@ -6,6 +6,7 @@ on: - 'weed/iam/**' - 'weed/s3api/**' - 'weed/credential/**' + - 'weed/pb/**' - 'test/s3/iam/**' - '.github/workflows/s3-iam-tests.yml' push: @@ -14,6 +15,7 @@ on: - 'weed/iam/**' - 'weed/s3api/**' - 'weed/credential/**' + - 'weed/pb/**' - 'test/s3/iam/**' - '.github/workflows/s3-iam-tests.yml' From 76fdae1c5c6c86c571c1c43c6cfa23d0ec89b876 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 15:58:35 -0700 Subject: [PATCH 27/72] test: replace time.Sleep with require.Eventually for propagation waits Use polling with timeout instead of fixed sleeps to reduce flakiness in integration tests waiting for IAM policy propagation. --- test/s3/iam/s3_iam_group_test.go | 70 ++++++++++++++++---------------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go index d8784278f..7b03d92a0 100644 --- a/test/s3/iam/s3_iam_group_test.go +++ b/test/s3/iam/s3_iam_group_test.go @@ -338,14 +338,13 @@ func TestIAMGroupPolicyEnforcement(t *testing.T) { }) require.NoError(t, err) - // Wait for policy propagation - time.Sleep(2 * time.Second) - - // Now user should be able to create the bucket through group policy - _, err = userS3Client.CreateBucket(&s3.CreateBucketInput{ - Bucket: aws.String(bucketName), - }) - require.NoError(t, err, "User with group policy should be allowed") + // 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") t.Cleanup(func() { userS3Client.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) }) @@ -367,14 +366,13 @@ func TestIAMGroupPolicyEnforcement(t *testing.T) { }) require.NoError(t, err) - // Wait for policy propagation - time.Sleep(2 * time.Second) - - // User should now be denied - _, err = userS3Client.ListObjects(&s3.ListObjectsInput{ - Bucket: aws.String(bucketName), - }) - assert.Error(t, err, "User removed from group should be denied") + // 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") }) } @@ -441,8 +439,6 @@ func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { }) require.NoError(t, err) - time.Sleep(2 * time.Second) - userS3Client := createS3Client(t, *keyResp.AccessKey.AccessKeyId, *keyResp.AccessKey.SecretAccessKey) // Create bucket using admin first so we can test listing @@ -453,10 +449,12 @@ func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { defer adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) t.Run("enabled_group_allows_access", func(t *testing.T) { - _, err := userS3Client.ListObjects(&s3.ListObjectsInput{ - Bucket: aws.String(bucketName), - }) - assert.NoError(t, err, "User in enabled group should have access") + 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) { @@ -468,13 +466,13 @@ func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() - // Wait for propagation - time.Sleep(2 * time.Second) - - _, err = userS3Client.ListObjects(&s3.ListObjectsInput{ - Bucket: aws.String(bucketName), - }) - assert.Error(t, err, "User in disabled group should be denied access") + // 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) { @@ -486,13 +484,13 @@ func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() - // Wait for propagation - time.Sleep(2 * time.Second) - - _, err = userS3Client.ListObjects(&s3.ListObjectsInput{ - Bucket: aws.String(bucketName), - }) - assert.NoError(t, err, "User in re-enabled group should have access again") + // 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") }) } From 66661de746faa2927f08b2673ee71114d7dd5d2f Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 19:27:31 -0700 Subject: [PATCH 28/72] fix: use credentialManager.GetPolicy for AttachGroupPolicy validation Policies created via CreatePolicy through credentialManager are stored in the credential store, not in s3cfg.Policies (which only has static config policies). Change AttachGroupPolicy to use credentialManager.GetPolicy() for policy existence validation. --- weed/s3api/s3api_embedded_iam.go | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index e89b6c39b..c1c1033b9 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -1583,7 +1583,7 @@ func (e *EmbeddedIamApi) RemoveUserFromGroup(s3cfg *iam_pb.S3ApiConfiguration, v return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} } -func (e *EmbeddedIamApi) AttachGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamAttachGroupPolicyResponse, *iamError) { +func (e *EmbeddedIamApi) AttachGroupPolicy(ctx context.Context, s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamAttachGroupPolicyResponse, *iamError) { resp := &iamAttachGroupPolicyResponse{} groupName := values.Get("GroupName") policyArn := values.Get("PolicyArn") @@ -1594,17 +1594,13 @@ func (e *EmbeddedIamApi) AttachGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, val if err != nil { return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err} } - // Verify policy exists - policyFound := false - for _, p := range s3cfg.Policies { - if p.Name == policyName { - policyFound = true - break + // Verify policy exists via credential manager + if e.credentialManager != nil { + policy, pErr := e.credentialManager.GetPolicy(ctx, policyName) + if pErr != nil || policy == nil { + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} } } - if !policyFound { - return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} - } for _, g := range s3cfg.Groups { if g.Name == groupName { // Check if already attached (idempotent) @@ -2073,7 +2069,7 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s } case "AttachGroupPolicy": var iamErr *iamError - response, iamErr = e.AttachGroupPolicy(s3cfg, values) + response, iamErr = e.AttachGroupPolicy(ctx, s3cfg, values) if iamErr != nil { return nil, iamErr } From 54dc6b4d4068d08811038b1410804f1007a18398 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 19:30:15 -0700 Subject: [PATCH 29/72] feat: add UpdateGroup handler to embedded IAM API Add UpdateGroup action to enable/disable groups and rename groups via the IAM API. This is a SeaweedFS extension (not in AWS SDK) used by tests to toggle group disabled status. --- weed/iam/responses.go | 6 ++++++ weed/s3api/s3api_embedded_iam.go | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/weed/iam/responses.go b/weed/iam/responses.go index 4757e7852..c5a7c7c32 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -296,6 +296,12 @@ type DeleteGroupResponse struct { CommonResponse } +// UpdateGroupResponse is the response for UpdateGroup action. +type UpdateGroupResponse struct { + XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateGroupResponse"` + CommonResponse +} + // GetGroupResponse is the response for GetGroup action. type GetGroupResponse struct { XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetGroupResponse"` diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index c1c1033b9..8cc96c369 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -116,6 +116,7 @@ type ( // Group response types iamCreateGroupResponse = iamlib.CreateGroupResponse iamDeleteGroupResponse = iamlib.DeleteGroupResponse + iamUpdateGroupResponse = iamlib.UpdateGroupResponse iamGetGroupResponse = iamlib.GetGroupResponse iamListGroupsResponse = iamlib.ListGroupsResponse iamAddUserToGroupResponse = iamlib.AddUserToGroupResponse @@ -1495,6 +1496,26 @@ func (e *EmbeddedIamApi) DeleteGroup(s3cfg *iam_pb.S3ApiConfiguration, values ur return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} } +func (e *EmbeddedIamApi) UpdateGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamUpdateGroupResponse, *iamError) { + resp := &iamUpdateGroupResponse{} + groupName := values.Get("GroupName") + if groupName == "" { + return resp, &iamError{Code: iam.ErrCodeInvalidInputException, Error: fmt.Errorf("GroupName is required")} + } + for _, g := range s3cfg.Groups { + if g.Name == groupName { + if disabled := values.Get("Disabled"); disabled != "" { + g.Disabled = disabled == "true" + } + if newName := values.Get("NewGroupName"); newName != "" { + g.Name = newName + } + return resp, nil + } + } + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("group %s does not exist", groupName)} +} + func (e *EmbeddedIamApi) GetGroup(s3cfg *iam_pb.S3ApiConfiguration, values url.Values) (*iamGetGroupResponse, *iamError) { resp := &iamGetGroupResponse{} groupName := values.Get("GroupName") @@ -2045,6 +2066,12 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s if iamErr != nil { return nil, iamErr } + case "UpdateGroup": + var iamErr *iamError + response, iamErr = e.UpdateGroup(s3cfg, values) + if iamErr != nil { + return nil, iamErr + } case "GetGroup": var iamErr *iamError response, iamErr = e.GetGroup(s3cfg, values) From 58964de34cfc1b9bbece3631ee71ee1382341054 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 19:30:20 -0700 Subject: [PATCH 30/72] fix: authenticate raw IAM API calls in group tests The embedded IAM endpoint rejects anonymous requests. Replace callIAMAPI with callIAMAPIAuthenticated that uses JWT bearer token authentication via the test framework. --- test/s3/iam/s3_iam_group_test.go | 42 ++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go index 7b03d92a0..08286634f 100644 --- a/test/s3/iam/s3_iam_group_test.go +++ b/test/s3/iam/s3_iam_group_test.go @@ -459,7 +459,7 @@ func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { t.Run("disabled_group_denies_access", func(t *testing.T) { // Disable group via raw IAM API (no SDK support for this extension) - resp, err := callIAMAPI(t, "UpdateGroup", url.Values{ + resp, err := callIAMAPIAuthenticated(t, framework, "UpdateGroup", url.Values{ "GroupName": {groupName}, "Disabled": {"true"}, }) @@ -477,7 +477,7 @@ func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { t.Run("re_enabled_group_restores_access", func(t *testing.T) { // Re-enable the group - resp, err := callIAMAPI(t, "UpdateGroup", url.Values{ + resp, err := callIAMAPIAuthenticated(t, framework, "UpdateGroup", url.Values{ "GroupName": {groupName}, "Disabled": {"false"}, }) @@ -614,9 +614,34 @@ type ListGroupsResponse struct { } `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, -// for operations not covered by the AWS SDK (like the SeaweedFS extension -// to disable/enable groups via UpdateGroup with Disabled parameter). +// verifying XML response format for group operations. func TestIAMGroupRawAPI(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test in short mode") @@ -625,10 +650,13 @@ func TestIAMGroupRawAPI(t *testing.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 := callIAMAPI(t, "CreateGroup", url.Values{ + resp, err := callIAMAPIAuthenticated(t, framework, "CreateGroup", url.Values{ "GroupName": {groupName}, }) require.NoError(t, err) @@ -645,7 +673,7 @@ func TestIAMGroupRawAPI(t *testing.T) { }) t.Run("list_groups_raw", func(t *testing.T) { - resp, err := callIAMAPI(t, "ListGroups", url.Values{}) + resp, err := callIAMAPIAuthenticated(t, framework, "ListGroups", url.Values{}) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -668,7 +696,7 @@ func TestIAMGroupRawAPI(t *testing.T) { }) t.Run("delete_group_raw", func(t *testing.T) { - resp, err := callIAMAPI(t, "DeleteGroup", url.Values{ + resp, err := callIAMAPIAuthenticated(t, framework, "DeleteGroup", url.Values{ "GroupName": {groupName}, }) require.NoError(t, err) From 81825f9d25488a54c593fbfd4a9b591596d5ae1c Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 19:31:25 -0700 Subject: [PATCH 31/72] feat: add UpdateGroup handler to standalone IAM API Mirror the embedded IAM UpdateGroup handler in the standalone IAM API for parity. --- weed/iamapi/iamapi_group_handlers.go | 20 ++++++++++++++++++++ weed/iamapi/iamapi_management_handlers.go | 7 +++++++ weed/iamapi/iamapi_response.go | 1 + 3 files changed, 28 insertions(+) diff --git a/weed/iamapi/iamapi_group_handlers.go b/weed/iamapi/iamapi_group_handlers.go index 49d386e0c..d6b440aba 100644 --- a/weed/iamapi/iamapi_group_handlers.go +++ b/weed/iamapi/iamapi_group_handlers.go @@ -46,6 +46,26 @@ func (iama *IamApiServer) DeleteGroup(s3cfg *iam_pb.S3ApiConfiguration, values u 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 != "" { + g.Disabled = disabled == "true" + } + if newName := values.Get("NewGroupName"); 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") diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index 8b9438014..bbe6065bd 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -1105,6 +1105,13 @@ func (iama *IamApiServer) DoActions(w http.ResponseWriter, r *http.Request) { writeIamErrorResponse(w, r, reqID, err) return } + case "UpdateGroup": + var err *IamError + response, err = iama.UpdateGroup(s3cfg, values) + if err != nil { + writeIamErrorResponse(w, r, reqID, err) + return + } case "GetGroup": var err *IamError response, err = iama.GetGroup(s3cfg, values) diff --git a/weed/iamapi/iamapi_response.go b/weed/iamapi/iamapi_response.go index a8dafb50c..56fcb1be1 100644 --- a/weed/iamapi/iamapi_response.go +++ b/weed/iamapi/iamapi_response.go @@ -39,6 +39,7 @@ type ( // Group response types CreateGroupResponse = iamlib.CreateGroupResponse DeleteGroupResponse = iamlib.DeleteGroupResponse + UpdateGroupResponse = iamlib.UpdateGroupResponse GetGroupResponse = iamlib.GetGroupResponse ListGroupsResponse = iamlib.ListGroupsResponse AddUserToGroupResponse = iamlib.AddUserToGroupResponse From b7096639363dff167179e2e94073faa475174a1b Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:11:49 -0700 Subject: [PATCH 32/72] fix: add omitempty to Marker XML tags in group responses Non-truncated responses should not emit an empty element. --- weed/iam/responses.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/weed/iam/responses.go b/weed/iam/responses.go index c5a7c7c32..fd91e27f4 100644 --- a/weed/iam/responses.go +++ b/weed/iam/responses.go @@ -309,7 +309,7 @@ type GetGroupResponse struct { Group iam.Group `xml:"Group"` Users []*iam.User `xml:"Users>member"` IsTruncated bool `xml:"IsTruncated"` - Marker string `xml:"Marker"` + Marker string `xml:"Marker,omitempty"` } `xml:"GetGroupResult"` CommonResponse } @@ -320,7 +320,7 @@ type ListGroupsResponse struct { ListGroupsResult struct { Groups []*iam.Group `xml:"Groups>member"` IsTruncated bool `xml:"IsTruncated"` - Marker string `xml:"Marker"` + Marker string `xml:"Marker,omitempty"` } `xml:"ListGroupsResult"` CommonResponse } @@ -355,7 +355,7 @@ type ListAttachedGroupPoliciesResponse struct { ListAttachedGroupPoliciesResult struct { AttachedPolicies []*iam.AttachedPolicy `xml:"AttachedPolicies>member"` IsTruncated bool `xml:"IsTruncated"` - Marker string `xml:"Marker"` + Marker string `xml:"Marker,omitempty"` } `xml:"ListAttachedGroupPoliciesResult"` CommonResponse } @@ -366,7 +366,7 @@ type ListGroupsForUserResponse struct { ListGroupsForUserResult struct { Groups []*iam.Group `xml:"Groups>member"` IsTruncated bool `xml:"IsTruncated"` - Marker string `xml:"Marker"` + Marker string `xml:"Marker,omitempty"` } `xml:"ListGroupsForUserResult"` CommonResponse } From 4e7302eaa3e46d4d469d51c19e3ba3bd951d802d Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:11:54 -0700 Subject: [PATCH 33/72] fix: distinguish backend errors from missing policies in AttachGroupPolicy Return ServiceFailure for credential manager errors instead of masking them as NoSuchEntity. Also switch ListGroupsForUser to use s3cfg.Groups instead of in-memory reverse index to avoid stale data. Add duplicate name check to UpdateGroup rename. --- weed/s3api/s3api_embedded_iam.go | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 8cc96c369..f45dc4e50 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -1507,7 +1507,12 @@ func (e *EmbeddedIamApi) UpdateGroup(s3cfg *iam_pb.S3ApiConfiguration, values ur if disabled := values.Get("Disabled"); disabled != "" { g.Disabled = disabled == "true" } - if newName := values.Get("NewGroupName"); newName != "" { + 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 @@ -1618,7 +1623,10 @@ func (e *EmbeddedIamApi) AttachGroupPolicy(ctx context.Context, s3cfg *iam_pb.S3 // Verify policy exists via credential manager if e.credentialManager != nil { policy, pErr := e.credentialManager.GetPolicy(ctx, policyName) - if pErr != nil || policy == nil { + if pErr != nil { + return resp, &iamError{Code: iam.ErrCodeServiceFailureException, Error: fmt.Errorf("failed to look up policy %s: %w", policyName, pErr)} + } + if policy == nil { return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("policy %s not found", policyName)} } } @@ -1701,13 +1709,15 @@ func (e *EmbeddedIamApi) ListGroupsForUser(s3cfg *iam_pb.S3ApiConfiguration, val if !userFound { return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf("user %s does not exist", userName)} } - // Use the in-memory reverse index for O(1) lookup - e.iam.m.RLock() - groupNames := e.iam.userGroups[userName] - e.iam.m.RUnlock() - for _, gName := range groupNames { - name := gName - resp.ListGroupsForUserResult.Groups = append(resp.ListGroupsForUserResult.Groups, &iam.Group{GroupName: &name}) + // Build from s3cfg.Groups for consistency with freshly loaded config + for _, g := range s3cfg.Groups { + for _, m := range g.Members { + if m == userName { + name := g.Name + resp.ListGroupsForUserResult.Groups = append(resp.ListGroupsForUserResult.Groups, &iam.Group{GroupName: &name}) + break + } + } } return resp, nil } From d1bb4b6cd03c10636800dd9dd47402e4ac97d06e Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:11:58 -0700 Subject: [PATCH 34/72] fix: standalone IAM AttachGroupPolicy uses persisted policy store Check managed policies from GetPolicies() instead of s3cfg.Policies so dynamically created policies are found. Also add duplicate name check to UpdateGroup rename. --- weed/iamapi/iamapi_group_handlers.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/weed/iamapi/iamapi_group_handlers.go b/weed/iamapi/iamapi_group_handlers.go index d6b440aba..fc4b17848 100644 --- a/weed/iamapi/iamapi_group_handlers.go +++ b/weed/iamapi/iamapi_group_handlers.go @@ -1,11 +1,13 @@ package iamapi import ( + "errors" "fmt" "net/url" "strings" "github.com/aws/aws-sdk-go/service/iam" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" ) @@ -57,7 +59,12 @@ func (iama *IamApiServer) UpdateGroup(s3cfg *iam_pb.S3ApiConfiguration, values u if disabled := values.Get("Disabled"); disabled != "" { g.Disabled = disabled == "true" } - if newName := values.Get("NewGroupName"); newName != "" { + 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 @@ -163,15 +170,12 @@ func (iama *IamApiServer) AttachGroupPolicy(s3cfg *iam_pb.S3ApiConfiguration, va if iamErr != nil { return resp, iamErr } - // Verify policy exists - policyFound := false - for _, p := range s3cfg.Policies { - if p.Name == policyName { - policyFound = true - break - } + // 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 !policyFound { + 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 { From e98e2231b08d62a547afd52705314ca5af1db913 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:12:26 -0700 Subject: [PATCH 35/72] fix: rollback inline policies on UpdateUser PutPolicies failure If PutPolicies fails after moving inline policies to the new username, restore both the identity name and the inline policies map to their original state to avoid a partial-write window. --- weed/iamapi/iamapi_management_handlers.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index bbe6065bd..54c06f47b 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -279,6 +279,10 @@ func (iama *IamApiServer) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values ur delete(policies.InlinePolicies, userName) policies.InlinePolicies[newUserName] = userPolicies if pErr := iama.s3ApiConfig.PutPolicies(&policies); pErr != nil { + // Rollback: restore identity name and inline policies + ident.Name = userName + delete(policies.InlinePolicies, newUserName) + policies.InlinePolicies[userName] = userPolicies return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} } } From afffa9cab928b3d7d8ff4102e12769429a407f78 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:13:55 -0700 Subject: [PATCH 36/72] fix: correct test cleanup ordering for group tests Replace scattered defers with single ordered t.Cleanup in each test to ensure resources are torn down in reverse-creation order: remove membership, detach policies, delete access keys, delete users, delete groups, delete policies. Move bucket cleanup to parent test scope and delete objects before bucket. --- test/s3/iam/s3_iam_group_test.go | 92 +++++++++++++++++++------------- 1 file changed, 55 insertions(+), 37 deletions(-) diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go index 08286634f..c6147fc10 100644 --- a/test/s3/iam/s3_iam_group_test.go +++ b/test/s3/iam/s3_iam_group_test.go @@ -196,7 +196,6 @@ func TestIAMGroupPolicyAttachment(t *testing.T) { GroupName: aws.String(groupName), }) require.NoError(t, err) - defer iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ PolicyName: aws.String(policyName), @@ -204,7 +203,16 @@ func TestIAMGroupPolicyAttachment(t *testing.T) { }) require.NoError(t, err) policyArn := createPolicyResp.Policy.Arn - defer iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}) + + // Cleanup in correct order: detach policy, delete group, delete policy + t.Cleanup(func() { + iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: policyArn, + }) + iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) + iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}) + }) t.Run("attach_group_policy", func(t *testing.T) { _, err := iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{ @@ -269,23 +277,12 @@ func TestIAMGroupPolicyEnforcement(t *testing.T) { UserName: aws.String(userName), }) require.NoError(t, err) - defer func() { - iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ - GroupName: aws.String(groupName), - UserName: aws.String(userName), - }) - iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) - }() // Create access key for the user keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{ UserName: aws.String(userName), }) require.NoError(t, err) - defer iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ - UserName: aws.String(userName), - AccessKeyId: keyResp.AccessKey.AccessKeyId, - }) accessKeyId := *keyResp.AccessKey.AccessKeyId secretKey := *keyResp.AccessKey.SecretAccessKey @@ -298,13 +295,6 @@ func TestIAMGroupPolicyEnforcement(t *testing.T) { GroupName: aws.String(groupName), }) require.NoError(t, err) - defer func() { - iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ - GroupName: aws.String(groupName), - PolicyArn: aws.String("arn:aws:iam:::policy/" + policyName), - }) - iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) - }() // Create policy createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ @@ -313,7 +303,35 @@ func TestIAMGroupPolicyEnforcement(t *testing.T) { }) require.NoError(t, err) policyArn := createPolicyResp.Policy.Arn - defer iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}) + + // Cleanup in correct order: remove user from group, detach policy, + // delete access key, delete user, delete group, delete policy + t.Cleanup(func() { + iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: aws.String(groupName), + UserName: aws.String(userName), + }) + iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + GroupName: aws.String(groupName), + PolicyArn: policyArn, + }) + iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + UserName: aws.String(userName), + AccessKeyId: keyResp.AccessKey.AccessKeyId, + }) + iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) + iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) + iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}) + }) + + // Register bucket cleanup on parent test so it runs after all subtests + t.Cleanup(func() { + userS3Client.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String("test-key"), + }) + userS3Client.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) + }) t.Run("user_without_group_denied", func(t *testing.T) { // User has no policies and is not in any group — should be denied @@ -345,9 +363,6 @@ func TestIAMGroupPolicyEnforcement(t *testing.T) { }) return err == nil }, 10*time.Second, 500*time.Millisecond, "User with group policy should be allowed") - t.Cleanup(func() { - userS3Client.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) - }) // Should also be able to put/get objects _, err = userS3Client.PutObject(&s3.PutObjectInput{ @@ -401,32 +416,35 @@ func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { // Create user, group, policy _, err = iamClient.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)}) require.NoError(t, err) - defer iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) keyResp, err := iamClient.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(userName)}) require.NoError(t, err) - defer iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ - UserName: aws.String(userName), AccessKeyId: keyResp.AccessKey.AccessKeyId, - }) _, err = iamClient.CreateGroup(&iam.CreateGroupInput{GroupName: aws.String(groupName)}) require.NoError(t, err) - defer func() { + + 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() { + iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + GroupName: aws.String(groupName), UserName: aws.String(userName), + }) iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ GroupName: aws.String(groupName), PolicyArn: aws.String("arn:aws:iam:::policy/" + policyName), }) - iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ - GroupName: aws.String(groupName), UserName: aws.String(userName), + iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + UserName: aws.String(userName), AccessKeyId: keyResp.AccessKey.AccessKeyId, }) + iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) - }() - - createPolicyResp, err := iamClient.CreatePolicy(&iam.CreatePolicyInput{ - PolicyName: aws.String(policyName), PolicyDocument: aws.String(policyDoc), + iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: createPolicyResp.Policy.Arn}) }) - require.NoError(t, err) - defer iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: createPolicyResp.Policy.Arn}) // Setup: attach policy, add user, create bucket with admin _, err = iamClient.AttachGroupPolicy(&iam.AttachGroupPolicyInput{ From 4f47773cd95bbc790f90cf380cde0dd5c3a38869 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:51:45 -0700 Subject: [PATCH 37/72] fix: move identity nil check before map lookup and refine hasAttachedPolicies Move the nil check on identity before accessing identity.Name to prevent panic. Also refine hasAttachedPolicies to only consider groups that are enabled and have actual policies attached, so membership in a no-policy group doesn't incorrectly trigger IAM authorization. --- weed/s3api/auth_credentials.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index d6db8b859..5e1744b6a 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -1865,6 +1865,10 @@ func determineIAMAuthPath(sessionToken, principal, principalArn string) iamAuthP // Returns true if any matching statement explicitly allows the action. // Uses the cached iamPolicyEngine to avoid re-parsing policy JSON on every request. func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identity *Identity, action Action, bucket, object string) bool { + if identity == nil { + return false + } + iam.m.RLock() engine := iam.iamPolicyEngine groupNames := iam.userGroups[identity.Name] @@ -1872,8 +1876,7 @@ func (iam *IdentityAccessManagement) evaluateIAMPolicies(r *http.Request, identi iam.m.RUnlock() // Collect all policy names: user policies + group policies - hasPolicies := len(identity.PolicyNames) > 0 || len(groupNames) > 0 - if identity == nil || !hasPolicies { + if len(identity.PolicyNames) == 0 && len(groupNames) == 0 { return false } @@ -1947,8 +1950,15 @@ func (iam *IdentityAccessManagement) VerifyActionPermission(r *http.Request, ide r.URL.Query().Get("X-Amz-Security-Token") != "" iam.m.RLock() userGroupNames := iam.userGroups[identity.Name] + groupsHavePolicies := false + for _, gn := range userGroupNames { + if g, ok := iam.groups[gn]; ok && !g.Disabled && len(g.PolicyNames) > 0 { + groupsHavePolicies = true + break + } + } iam.m.RUnlock() - hasAttachedPolicies := len(identity.PolicyNames) > 0 || len(userGroupNames) > 0 + hasAttachedPolicies := len(identity.PolicyNames) > 0 || groupsHavePolicies if (len(identity.Actions) == 0 || hasSessionToken || hasAttachedPolicies) && iam.iamIntegration != nil { return iam.authorizeWithIAM(r, identity, action, bucket, object) From cc2fb6a616aa5f44284dbb56ee5a488ec8b35062 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:51:50 -0700 Subject: [PATCH 38/72] fix: fail group reload on unreadable or corrupt group files Return errors instead of logging and continuing when group files cannot be read or unmarshaled. This prevents silently applying a partial IAM config with missing group memberships or policies. --- weed/credential/filer_etc/filer_etc_group.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/weed/credential/filer_etc/filer_etc_group.go b/weed/credential/filer_etc/filer_etc_group.go index 04ae00b73..36cbeb19d 100644 --- a/weed/credential/filer_etc/filer_etc_group.go +++ b/weed/credential/filer_etc/filer_etc_group.go @@ -9,7 +9,6 @@ import ( "github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/filer" - "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" ) @@ -38,8 +37,7 @@ func (store *FilerEtcStore) loadGroupsFromMultiFile(ctx context.Context, s3cfg * } else { c, err := filer.ReadInsideFiler(ctx, client, dir, entry.Name) if err != nil { - glog.Warningf("Failed to read group file %s: %v", entry.Name, err) - continue + return fmt.Errorf("failed to read group file %s: %w", entry.Name, err) } content = c } @@ -47,8 +45,7 @@ func (store *FilerEtcStore) loadGroupsFromMultiFile(ctx context.Context, s3cfg * if len(content) > 0 { g := &iam_pb.Group{} if err := json.Unmarshal(content, g); err != nil { - glog.Warningf("Failed to unmarshal group %s: %v", entry.Name, err) - continue + return fmt.Errorf("failed to unmarshal group %s: %w", entry.Name, err) } s3cfg.Groups = append(s3cfg.Groups, g) } From 7643f18b330576f2198231c5299a08a865382458 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:51:55 -0700 Subject: [PATCH 39/72] fix: use errors.Is for sql.ErrNoRows comparison in postgres group store --- weed/credential/postgres/postgres_group.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/weed/credential/postgres/postgres_group.go b/weed/credential/postgres/postgres_group.go index 12850f641..0cf361ab6 100644 --- a/weed/credential/postgres/postgres_group.go +++ b/weed/credential/postgres/postgres_group.go @@ -42,7 +42,7 @@ func (store *PostgresStore) GetGroup(ctx context.Context, groupName string) (*ia `SELECT members, policy_names, disabled FROM groups WHERE name = $1`, groupName). Scan(&membersJSON, &policyNamesJSON, &disabled) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil, credential.ErrGroupNotFound } return nil, fmt.Errorf("failed to get group: %w", err) From 85aa6e345bf72f64a7ec448a3485a43f78847081 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:52:01 -0700 Subject: [PATCH 40/72] docs: explain why group methods skip propagateChange Group changes propagate to S3 servers via filer subscription (watching /etc/iam/groups/) rather than gRPC RPCs, since there are no group-specific RPCs in the S3 cache protocol. --- weed/credential/propagating_store.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/weed/credential/propagating_store.go b/weed/credential/propagating_store.go index d43ec6746..86ba48d74 100644 --- a/weed/credential/propagating_store.go +++ b/weed/credential/propagating_store.go @@ -386,6 +386,10 @@ func (s *PropagatingCredentialStore) DeleteServiceAccount(ctx context.Context, i return nil } +// Group methods do not call propagateChange because there are no group-specific +// gRPC RPCs in the S3 cache protocol. Group changes are propagated to S3 servers +// via the filer subscription mechanism (watching /etc/iam/groups/ directory). + func (s *PropagatingCredentialStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error { glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateGroup %s", group.Name) return s.CredentialStore.CreateGroup(ctx, group) From 0b576dec792bd2627deb6ddab8ea6668684067e7 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:52:05 -0700 Subject: [PATCH 41/72] fix: remove unused policyNameFromArn and strings import --- weed/iamapi/iamapi_group_handlers.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/weed/iamapi/iamapi_group_handlers.go b/weed/iamapi/iamapi_group_handlers.go index fc4b17848..7c6f356de 100644 --- a/weed/iamapi/iamapi_group_handlers.go +++ b/weed/iamapi/iamapi_group_handlers.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/url" - "strings" "github.com/aws/aws-sdk-go/service/iam" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" @@ -325,7 +324,3 @@ func buildUserGroupsIndex(s3cfg *iam_pb.S3ApiConfiguration) map[string][]string return index } -// policyNameFromArn extracts policy name from ARN for standalone handlers. -func policyNameFromArn(policyArn string) string { - return strings.TrimPrefix(policyArn, policyArnPrefix) -} From f4abf64292b0bea892f64cbdfa8ef348baa99018 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:52:10 -0700 Subject: [PATCH 42/72] fix: update service account ParentUser on user rename When renaming a user via UpdateUser, also update ParentUser references in service accounts to prevent them from becoming orphaned after the next configuration reload. --- weed/iamapi/iamapi_management_handlers.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index 54c06f47b..7793f4547 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -289,6 +289,12 @@ func (iama *IamApiServer) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values ur } // Update group membership references updateUserInGroups(s3cfg, userName, newUserName) + // Update service account parent references + for _, sa := range s3cfg.ServiceAccounts { + if sa.ParentUser == userName { + sa.ParentUser = newUserName + } + } return resp, nil } } From cabcd5a697c533daa89f99d18796c90b614e4c5b Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:52:18 -0700 Subject: [PATCH 43/72] fix: wrap DetachGroupPolicy error with ErrPolicyNotAttached sentinel Use credential.ErrPolicyNotAttached so groupErrorToHTTPStatus maps it to 400 instead of falling back to 500. --- weed/admin/dash/group_management.go | 2 +- weed/admin/handlers/group_handlers.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/weed/admin/dash/group_management.go b/weed/admin/dash/group_management.go index a06973faa..c95fe16f1 100644 --- a/weed/admin/dash/group_management.go +++ b/weed/admin/dash/group_management.go @@ -207,7 +207,7 @@ func (s *AdminServer) DetachGroupPolicy(ctx context.Context, groupName, policyNa } } if !found { - return fmt.Errorf("policy %s is not attached to group %s", policyName, groupName) + 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 { diff --git a/weed/admin/handlers/group_handlers.go b/weed/admin/handlers/group_handlers.go index 0539352d2..d6dccd613 100644 --- a/weed/admin/handlers/group_handlers.go +++ b/weed/admin/handlers/group_handlers.go @@ -24,6 +24,9 @@ func groupErrorToHTTPStatus(err error) int { if errors.Is(err, credential.ErrUserNotInGroup) { return http.StatusBadRequest } + if errors.Is(err, credential.ErrPolicyNotAttached) { + return http.StatusBadRequest + } return http.StatusInternalServerError } From 8dd79e8fa4c97baea9245d11fdba950e496b72a8 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 20:52:23 -0700 Subject: [PATCH 44/72] fix: use admin S3 client for bucket cleanup in enforcement test The user S3 client may lack permissions by cleanup time since the user is removed from the group in an earlier subtest. Use the admin S3 client to ensure bucket and object cleanup always succeeds. --- test/s3/iam/s3_iam_group_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go index c6147fc10..26070b3f2 100644 --- a/test/s3/iam/s3_iam_group_test.go +++ b/test/s3/iam/s3_iam_group_test.go @@ -324,13 +324,16 @@ func TestIAMGroupPolicyEnforcement(t *testing.T) { iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}) }) - // Register bucket cleanup on parent test so it runs after all subtests + // 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() { - userS3Client.DeleteObject(&s3.DeleteObjectInput{ + adminS3.DeleteObject(&s3.DeleteObjectInput{ Bucket: aws.String(bucketName), Key: aws.String("test-key"), }) - userS3Client.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) + adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) }) t.Run("user_without_group_denied", func(t *testing.T) { From 36d11f3cb3eeb9090a275117a7e702a10143be4a Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 21:20:06 -0700 Subject: [PATCH 45/72] fix: add nil guard for group param in propagating store log calls Prevent potential nil dereference when logging group.Name in CreateGroup and UpdateGroup of PropagatingCredentialStore. --- weed/credential/propagating_store.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/weed/credential/propagating_store.go b/weed/credential/propagating_store.go index 86ba48d74..141890747 100644 --- a/weed/credential/propagating_store.go +++ b/weed/credential/propagating_store.go @@ -391,7 +391,9 @@ func (s *PropagatingCredentialStore) DeleteServiceAccount(ctx context.Context, i // via the filer subscription mechanism (watching /etc/iam/groups/ directory). func (s *PropagatingCredentialStore) CreateGroup(ctx context.Context, group *iam_pb.Group) error { - glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateGroup %s", group.Name) + if group != nil { + glog.V(4).Infof("IAM: PropagatingCredentialStore.CreateGroup %s", group.Name) + } return s.CredentialStore.CreateGroup(ctx, group) } @@ -409,6 +411,8 @@ func (s *PropagatingCredentialStore) ListGroups(ctx context.Context) ([]string, } func (s *PropagatingCredentialStore) UpdateGroup(ctx context.Context, group *iam_pb.Group) error { - glog.V(4).Infof("IAM: PropagatingCredentialStore.UpdateGroup %s", group.Name) + if group != nil { + glog.V(4).Infof("IAM: PropagatingCredentialStore.UpdateGroup %s", group.Name) + } return s.CredentialStore.UpdateGroup(ctx, group) } From a68e69826c4702b3bc77724b0c3ecc5f4109a30b Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 21:20:11 -0700 Subject: [PATCH 46/72] fix: validate Disabled field in UpdateGroup handlers Reject values other than "true" or "false" with InvalidInputException instead of silently treating them as false. --- weed/iamapi/iamapi_group_handlers.go | 3 +++ weed/s3api/s3api_embedded_iam.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/weed/iamapi/iamapi_group_handlers.go b/weed/iamapi/iamapi_group_handlers.go index 7c6f356de..b2a3545f6 100644 --- a/weed/iamapi/iamapi_group_handlers.go +++ b/weed/iamapi/iamapi_group_handlers.go @@ -56,6 +56,9 @@ func (iama *IamApiServer) UpdateGroup(s3cfg *iam_pb.S3ApiConfiguration, values u 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 { diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index f45dc4e50..6a0e9c2ab 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -1505,6 +1505,9 @@ func (e *EmbeddedIamApi) UpdateGroup(s3cfg *iam_pb.S3ApiConfiguration, values ur 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 { From cdff00d8324cf6aa87fcb2705c9fc0f90207d345 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 21:20:16 -0700 Subject: [PATCH 47/72] fix: seed mergedGroups from existing groups in MergeS3ApiConfiguration Previously the merge started with empty group maps, dropping any static-file groups. Now seeds from existing iam.groups before overlaying dynamic config, and builds the reverse index after merging to avoid stale entries from overridden groups. --- weed/s3api/auth_credentials.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 5e1744b6a..bae09c012 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -761,6 +761,10 @@ func (iam *IdentityAccessManagement) MergeS3ApiConfiguration(config *iam_pb.S3Ap for k, v := range iam.staticIdentityNames { staticNames[k] = v } + existingGroups := make(map[string]*iam_pb.Group) + for k, v := range iam.groups { + existingGroups[k] = v + } iam.m.RUnlock() // Process accounts from dynamic config (can add new accounts) @@ -925,11 +929,17 @@ func (iam *IdentityAccessManagement) MergeS3ApiConfiguration(config *iam_pb.S3Ap policies[policy.Name] = policy } - // Process groups from dynamic config + // Process groups: seed from existing (static) groups, then overlay dynamic config mergedGroups := make(map[string]*iam_pb.Group) - mergedUserGroups := make(map[string][]string) + for k, v := range existingGroups { + mergedGroups[k] = v + } for _, g := range config.Groups { mergedGroups[g.Name] = g + } + // Build reverse index from final merged groups + mergedUserGroups := make(map[string][]string) + for _, g := range mergedGroups { if !g.Disabled { for _, member := range g.Members { mergedUserGroups[member] = append(mergedUserGroups[member], g.Name) From 674deb10a090a9e58d2dc75c0c5aca2d51116a7d Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 21:20:22 -0700 Subject: [PATCH 48/72] fix: use errors.Is for filer_pb.ErrNotFound comparison in group loading Replace direct equality (==) with errors.Is() to correctly match wrapped errors, consistent with the rest of the codebase. --- weed/credential/filer_etc/filer_etc_group.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/weed/credential/filer_etc/filer_etc_group.go b/weed/credential/filer_etc/filer_etc_group.go index 36cbeb19d..250930bc8 100644 --- a/weed/credential/filer_etc/filer_etc_group.go +++ b/weed/credential/filer_etc/filer_etc_group.go @@ -20,7 +20,7 @@ func (store *FilerEtcStore) loadGroupsFromMultiFile(ctx context.Context, s3cfg * dir := filer.IamConfigDirectory + "/" + IamGroupsDirectory entries, err := listEntries(ctx, client, dir) if err != nil { - if err == filer_pb.ErrNotFound { + if errors.Is(err, filer_pb.ErrNotFound) { return nil } return err @@ -109,7 +109,7 @@ func (store *FilerEtcStore) GetGroup(ctx context.Context, groupName string) (*ia err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { data, err := filer.ReadInsideFiler(ctx, client, filer.IamConfigDirectory+"/"+IamGroupsDirectory, groupName+".json") if err != nil { - if err == filer_pb.ErrNotFound { + if errors.Is(err, filer_pb.ErrNotFound) { return credential.ErrGroupNotFound } return err @@ -135,7 +135,7 @@ func (store *FilerEtcStore) ListGroups(ctx context.Context) ([]string, error) { err := store.withFilerClient(func(client filer_pb.SeaweedFilerClient) error { entries, err := listEntries(ctx, client, filer.IamConfigDirectory+"/"+IamGroupsDirectory) if err != nil { - if err == filer_pb.ErrNotFound { + if errors.Is(err, filer_pb.ErrNotFound) { return nil } return err From 2d783a5ba74436272ac3b7a23707e5ad32b0170e Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 21:20:26 -0700 Subject: [PATCH 49/72] fix: add ErrUserNotFound and ErrPolicyNotFound to groupErrorToHTTPStatus Map these sentinel errors to 404 so AddGroupMember and AttachGroupPolicy return proper HTTP status codes. --- weed/admin/handlers/group_handlers.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/weed/admin/handlers/group_handlers.go b/weed/admin/handlers/group_handlers.go index d6dccd613..cd2aa38fb 100644 --- a/weed/admin/handlers/group_handlers.go +++ b/weed/admin/handlers/group_handlers.go @@ -27,6 +27,12 @@ func groupErrorToHTTPStatus(err error) int { 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 + } return http.StatusInternalServerError } From 2c0b1af043c7284a5ea57622b4893b8a471ae271 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 21:20:30 -0700 Subject: [PATCH 50/72] fix: log cleanup errors in group integration tests Replace fire-and-forget cleanup calls with error-checked versions that log failures via t.Logf for debugging visibility. --- test/s3/iam/s3_iam_group_test.go | 84 ++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go index 26070b3f2..9103180cc 100644 --- a/test/s3/iam/s3_iam_group_test.go +++ b/test/s3/iam/s3_iam_group_test.go @@ -206,12 +206,18 @@ func TestIAMGroupPolicyAttachment(t *testing.T) { // Cleanup in correct order: detach policy, delete group, delete policy t.Cleanup(func() { - iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + if _, err := iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ GroupName: aws.String(groupName), PolicyArn: policyArn, - }) - iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) - iamClient.DeletePolicy(&iam.DeletePolicyInput{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) { @@ -307,21 +313,33 @@ func TestIAMGroupPolicyEnforcement(t *testing.T) { // Cleanup in correct order: remove user from group, detach policy, // delete access key, delete user, delete group, delete policy t.Cleanup(func() { - iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + if _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ GroupName: aws.String(groupName), UserName: aws.String(userName), - }) - iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + }); 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, - }) - iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + }); 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, - }) - iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) - iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) - iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: policyArn}) + }); 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 @@ -329,11 +347,15 @@ func TestIAMGroupPolicyEnforcement(t *testing.T) { adminS3, err := framework.CreateS3ClientWithJWT("admin-user", "TestAdminRole") require.NoError(t, err) t.Cleanup(func() { - adminS3.DeleteObject(&s3.DeleteObjectInput{ + if _, err := adminS3.DeleteObject(&s3.DeleteObjectInput{ Bucket: aws.String(bucketName), Key: aws.String("test-key"), - }) - adminS3.DeleteBucket(&s3.DeleteBucketInput{Bucket: aws.String(bucketName)}) + }); 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) { @@ -434,19 +456,31 @@ func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { // Cleanup in correct order: remove user from group, detach policy, // delete access key, delete user, delete group, delete policy t.Cleanup(func() { - iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ + if _, err := iamClient.RemoveUserFromGroup(&iam.RemoveUserFromGroupInput{ GroupName: aws.String(groupName), UserName: aws.String(userName), - }) - iamClient.DetachGroupPolicy(&iam.DetachGroupPolicyInput{ + }); 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), - }) - iamClient.DeleteAccessKey(&iam.DeleteAccessKeyInput{ + }); 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, - }) - iamClient.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}) - iamClient.DeleteGroup(&iam.DeleteGroupInput{GroupName: aws.String(groupName)}) - iamClient.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: createPolicyResp.Policy.Arn}) + }); 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 From accf47c80b4a65aefb0ba3486d165671d1299f37 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 22:55:37 -0700 Subject: [PATCH 51/72] fix: prevent duplicate group test runs in CI matrix The basic lane's -run "TestIAM" regex also matched TestIAMGroup* tests, causing them to run in both the basic and group lanes. Replace with explicit test function names. --- .github/workflows/s3-iam-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/s3-iam-tests.yml b/.github/workflows/s3-iam-tests.yml index 39eebdee5..80010b782 100644 --- a/.github/workflows/s3-iam-tests.yml +++ b/.github/workflows/s3-iam-tests.yml @@ -121,7 +121,7 @@ jobs: "basic") echo "Running basic IAM functionality tests..." make clean setup start-services wait-for-services - go test -v -timeout 15m -run "TestS3IAMAuthentication|TestS3IAMBasicWorkflow|TestS3IAMTokenValidation|TestIAM" ./... + go test -v -timeout 15m -run "TestS3IAMAuthentication|TestS3IAMBasicWorkflow|TestS3IAMTokenValidation|TestIAMUserManagement|TestIAMAccessKeyManagement|TestIAMPolicyManagement" ./... ;; "advanced") echo "Running advanced IAM feature tests..." From a35be2b6690ea83d8d34e2ab61852f8a3a94fe08 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 22:55:42 -0700 Subject: [PATCH 52/72] fix: add GIN index on groups.members JSONB for membership lookups Without this index, ListGroupsForUser and membership queries require full table scans on the groups table. --- weed/credential/postgres/postgres_store.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/weed/credential/postgres/postgres_store.go b/weed/credential/postgres/postgres_store.go index 4c9a9771c..3ba4810d6 100644 --- a/weed/credential/postgres/postgres_store.go +++ b/weed/credential/postgres/postgres_store.go @@ -184,6 +184,12 @@ func (store *PostgresStore) createTables() error { return fmt.Errorf("failed to create groups disabled index: %w", err) } + // Create GIN index on groups members JSONB for membership lookups + groupsMembersIndex := `CREATE INDEX IF NOT EXISTS idx_groups_members_gin ON groups USING GIN (members);` + if _, err := store.db.Exec(groupsMembersIndex); err != nil { + return fmt.Errorf("failed to create groups members index: %w", err) + } + return nil } From 41c07ba1686ed648dd32b62393db323b2c8f22ff Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 22:55:48 -0700 Subject: [PATCH 53/72] fix: handle cross-directory moves in IAM config subscription When a file is moved out of an IAM directory (e.g., /etc/iam/groups), the dir variable was overwritten with NewParentPath, causing the source directory change to be missed. Now also notifies handlers about the source directory for cross-directory moves. --- weed/s3api/auth_credentials_subscribe.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/weed/s3api/auth_credentials_subscribe.go b/weed/s3api/auth_credentials_subscribe.go index e491ee6a1..38aacc768 100644 --- a/weed/s3api/auth_credentials_subscribe.go +++ b/weed/s3api/auth_credentials_subscribe.go @@ -19,7 +19,9 @@ func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, p message := resp.EventNotification - // For rename/move operations, NewParentPath contains the destination directory + // For rename/move operations, NewParentPath contains the destination directory. + // We process both source and destination dirs so moves out of watched + // directories (e.g., IAM config dirs) are not missed. dir := resp.Directory if message.NewParentPath != "" { dir = message.NewParentPath @@ -31,6 +33,11 @@ func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, p _ = s3a.onIamConfigChange(dir, message.OldEntry, message.NewEntry) _ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, message.NewEntry) + // For moves across directories, also notify handlers about the source directory + if message.NewParentPath != "" && resp.Directory != message.NewParentPath { + _ = s3a.onIamConfigChange(resp.Directory, message.OldEntry, message.NewEntry) + } + return nil } From a06d6c56e6be7958e0556cc9c4e429d28307bc57 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 23:50:09 -0700 Subject: [PATCH 54/72] fix: validate members/policies before deleting group in admin handler AdminServer.DeleteGroup now checks for attached members and policies before delegating to credentialManager, matching the IAM handler guards. --- weed/admin/dash/group_management.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/weed/admin/dash/group_management.go b/weed/admin/dash/group_management.go index c95fe16f1..155027e78 100644 --- a/weed/admin/dash/group_management.go +++ b/weed/admin/dash/group_management.go @@ -102,6 +102,17 @@ 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)", name, len(g.Members)) + } + if len(g.PolicyNames) > 0 { + return fmt.Errorf("cannot delete group %s: group has %d attached policy(ies)", name, len(g.PolicyNames)) + } if err := s.credentialManager.DeleteGroup(ctx, name); err != nil { return fmt.Errorf("failed to delete group: %w", err) } From 59626e22c3056b2e0c519b95b064650874c7ac26 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 23:50:15 -0700 Subject: [PATCH 55/72] fix: merge groups by name instead of blind append during filer load Match the identity loader's merge behavior: find existing group by name and replace, only append when no match exists. Prevents duplicates when legacy and multi-file configs overlap. --- weed/credential/filer_etc/filer_etc_group.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/weed/credential/filer_etc/filer_etc_group.go b/weed/credential/filer_etc/filer_etc_group.go index 250930bc8..624a42a5a 100644 --- a/weed/credential/filer_etc/filer_etc_group.go +++ b/weed/credential/filer_etc/filer_etc_group.go @@ -47,7 +47,18 @@ func (store *FilerEtcStore) loadGroupsFromMultiFile(ctx context.Context, s3cfg * if err := json.Unmarshal(content, g); err != nil { return fmt.Errorf("failed to unmarshal group %s: %w", entry.Name, err) } - s3cfg.Groups = append(s3cfg.Groups, g) + // 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 From 0e00a1db703c2a18d267efa12814e02a663f6e4a Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 23:50:20 -0700 Subject: [PATCH 56/72] fix: check DeleteEntry response error when cleaning obsolete group files Capture and log resp.Error from filer DeleteEntry calls during group file cleanup, matching the pattern used in deleteGroupFile. --- weed/credential/filer_etc/filer_etc_identity.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/weed/credential/filer_etc/filer_etc_identity.go b/weed/credential/filer_etc/filer_etc_identity.go index 4046a934a..9c4900ab1 100644 --- a/weed/credential/filer_etc/filer_etc_identity.go +++ b/weed/credential/filer_etc/filer_etc_identity.go @@ -271,11 +271,14 @@ func (store *FilerEtcStore) SaveConfiguration(ctx context.Context, config *iam_p for _, entry := range entries { if !entry.IsDirectory && !validNames[entry.Name] { - if _, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{ + resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{ Directory: dir, Name: entry.Name, - }); err != nil { + }) + if err != nil { glog.Warningf("Failed to delete obsolete group file %s: %v", entry.Name, err) + } else if resp != nil && resp.Error != "" { + glog.Warningf("Failed to delete obsolete group file %s: %s", entry.Name, resp.Error) } } } From d37500180e1bd692fd27f6404c6cc04640e94a3f Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 23:50:26 -0700 Subject: [PATCH 57/72] fix: verify source user exists before no-op check in UpdateUser Reorder UpdateUser to find the source identity first and return NoSuchEntityException if not found, before checking if the rename is a no-op. Previously a non-existent user renamed to itself would incorrectly return success. --- weed/iamapi/iamapi_management_handlers.go | 110 ++++++++++++---------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/weed/iamapi/iamapi_management_handlers.go b/weed/iamapi/iamapi_management_handlers.go index 7793f4547..d1d7487f8 100644 --- a/weed/iamapi/iamapi_management_handlers.go +++ b/weed/iamapi/iamapi_management_handlers.go @@ -242,66 +242,74 @@ func (iama *IamApiServer) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values ur resp = &UpdateUserResponse{} userName := values.Get("UserName") newUserName := values.Get("NewUserName") - if newUserName != "" { - // No-op if renaming to the same name - if newUserName == userName { - return resp, nil + if newUserName == "" { + return resp, nil + } + + // Find the source identity first + var sourceIdent *iam_pb.Identity + for _, ident := range s3cfg.Identities { + if ident.Name == userName { + sourceIdent = ident + break } - // Check for name collision before renaming - for _, ident := range s3cfg.Identities { - if ident.Name == newUserName { - return resp, &IamError{ - Code: iam.ErrCodeEntityAlreadyExistsException, - Error: fmt.Errorf("user %s already exists", newUserName), - } + } + if sourceIdent == nil { + return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)} + } + + // No-op if renaming to the same name + if newUserName == userName { + return resp, nil + } + + // Check for name collision before renaming + for _, ident := range s3cfg.Identities { + if ident.Name == newUserName { + return resp, &IamError{ + Code: iam.ErrCodeEntityAlreadyExistsException, + Error: fmt.Errorf("user %s already exists", newUserName), } } - // Check for inline policy collision - policies := Policies{} - if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) { - return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} - } - if policies.InlinePolicies != nil { - if _, exists := policies.InlinePolicies[newUserName]; exists { - return resp, &IamError{ - Code: iam.ErrCodeEntityAlreadyExistsException, - Error: fmt.Errorf("inline policies already exist for user %s", newUserName), - } + } + // Check for inline policy collision + policies := Policies{} + if pErr := iama.s3ApiConfig.GetPolicies(&policies); pErr != nil && !errors.Is(pErr, filer_pb.ErrNotFound) { + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} + } + if policies.InlinePolicies != nil { + if _, exists := policies.InlinePolicies[newUserName]; exists { + return resp, &IamError{ + Code: iam.ErrCodeEntityAlreadyExistsException, + Error: fmt.Errorf("inline policies already exist for user %s", newUserName), } } + } - for _, ident := range s3cfg.Identities { - if userName == ident.Name { - ident.Name = newUserName - // Move any inline policies from old username to new username - if policies.InlinePolicies != nil { - if userPolicies, exists := policies.InlinePolicies[userName]; exists { - delete(policies.InlinePolicies, userName) - policies.InlinePolicies[newUserName] = userPolicies - if pErr := iama.s3ApiConfig.PutPolicies(&policies); pErr != nil { - // Rollback: restore identity name and inline policies - ident.Name = userName - delete(policies.InlinePolicies, newUserName) - policies.InlinePolicies[userName] = userPolicies - return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} - } - } - } - // Update group membership references - updateUserInGroups(s3cfg, userName, newUserName) - // Update service account parent references - for _, sa := range s3cfg.ServiceAccounts { - if sa.ParentUser == userName { - sa.ParentUser = newUserName - } - } - return resp, nil + sourceIdent.Name = newUserName + // Move any inline policies from old username to new username + if policies.InlinePolicies != nil { + if userPolicies, exists := policies.InlinePolicies[userName]; exists { + delete(policies.InlinePolicies, userName) + policies.InlinePolicies[newUserName] = userPolicies + if pErr := iama.s3ApiConfig.PutPolicies(&policies); pErr != nil { + // Rollback: restore identity name and inline policies + sourceIdent.Name = userName + delete(policies.InlinePolicies, newUserName) + policies.InlinePolicies[userName] = userPolicies + return resp, &IamError{Code: iam.ErrCodeServiceFailureException, Error: pErr} } } - } else { - return resp, nil } - return resp, &IamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(USER_DOES_NOT_EXIST, userName)} + // Update group membership references + updateUserInGroups(s3cfg, userName, newUserName) + // Update service account parent references + for _, sa := range s3cfg.ServiceAccounts { + if sa.ParentUser == userName { + sa.ParentUser = newUserName + } + } + return resp, nil } func GetPolicyDocument(policy *string) (policy_engine.PolicyDocument, error) { From 6ef8a3f5653435d598fc0c7fe683271056d13d1f Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 23:50:31 -0700 Subject: [PATCH 58/72] fix: update service account parent refs on user rename in embedded IAM The embedded IAM UpdateUser handler updated group membership but not service account ParentUser fields, unlike the standalone handler. --- weed/s3api/s3api_embedded_iam.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 6a0e9c2ab..671de5853 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -361,6 +361,12 @@ func (e *EmbeddedIamApi) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values url } } } + // Update service account parent references + for _, sa := range s3cfg.ServiceAccounts { + if sa.ParentUser == userName { + sa.ParentUser = newUserName + } + } return resp, nil } } From a65a971f0ad318c9e300a8f5aaea3c29ebf438b7 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 23:50:36 -0700 Subject: [PATCH 59/72] fix: replay source-side events for all handlers on cross-dir moves Pass nil newEntry to bucket, IAM, and circuit-breaker handlers for the source directory during cross-directory moves, so all watchers can clear caches for the moved-away resource. --- weed/s3api/auth_credentials_subscribe.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/weed/s3api/auth_credentials_subscribe.go b/weed/s3api/auth_credentials_subscribe.go index 38aacc768..964ee4822 100644 --- a/weed/s3api/auth_credentials_subscribe.go +++ b/weed/s3api/auth_credentials_subscribe.go @@ -35,7 +35,9 @@ func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, p // For moves across directories, also notify handlers about the source directory if message.NewParentPath != "" && resp.Directory != message.NewParentPath { - _ = s3a.onIamConfigChange(resp.Directory, message.OldEntry, message.NewEntry) + _ = s3a.onBucketMetadataChange(resp.Directory, message.OldEntry, nil) + _ = s3a.onIamConfigChange(resp.Directory, message.OldEntry, nil) + _ = s3a.onCircuitBreakerConfigChange(resp.Directory, message.OldEntry, nil) } return nil From b9bb2d698cb538c75a634738948f4dca657d7544 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 23:50:42 -0700 Subject: [PATCH 60/72] fix: don't seed mergedGroups from existing iam.groups in merge Groups are always dynamic (from filer), never static (from s3.config). Seeding from iam.groups caused stale deleted groups to persist. Now only uses config.Groups from the dynamic filer config. --- weed/s3api/auth_credentials.go | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index bae09c012..5d2a2da22 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -761,10 +761,6 @@ func (iam *IdentityAccessManagement) MergeS3ApiConfiguration(config *iam_pb.S3Ap for k, v := range iam.staticIdentityNames { staticNames[k] = v } - existingGroups := make(map[string]*iam_pb.Group) - for k, v := range iam.groups { - existingGroups[k] = v - } iam.m.RUnlock() // Process accounts from dynamic config (can add new accounts) @@ -929,17 +925,11 @@ func (iam *IdentityAccessManagement) MergeS3ApiConfiguration(config *iam_pb.S3Ap policies[policy.Name] = policy } - // Process groups: seed from existing (static) groups, then overlay dynamic config + // Process groups from dynamic config (groups are always dynamic, never in static s3.config) mergedGroups := make(map[string]*iam_pb.Group) - for k, v := range existingGroups { - mergedGroups[k] = v - } + mergedUserGroups := make(map[string][]string) for _, g := range config.Groups { mergedGroups[g.Name] = g - } - // Build reverse index from final merged groups - mergedUserGroups := make(map[string][]string) - for _, g := range mergedGroups { if !g.Disabled { for _, member := range g.Members { mergedUserGroups[member] = append(mergedUserGroups[member], g.Name) From f46c74a3cae678447bbf0e6b4e7e0cc31aeea3d2 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 8 Mar 2026 23:50:48 -0700 Subject: [PATCH 61/72] fix: add deferred user cleanup in TestIAMGroupUserDeletionSideEffect Register t.Cleanup for the created user so it gets cleaned up even if the test fails before the inline DeleteUser call. --- test/s3/iam/s3_iam_group_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go index 9103180cc..343e0c6b6 100644 --- a/test/s3/iam/s3_iam_group_test.go +++ b/test/s3/iam/s3_iam_group_test.go @@ -567,6 +567,10 @@ func TestIAMGroupUserDeletionSideEffect(t *testing.T) { _, 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{ From 0add8c58f5756557ade22a9076d532986389ca16 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 00:31:56 -0700 Subject: [PATCH 62/72] fix: assert UpdateGroup HTTP status in disabled group tests Add require.Equal checks for 200 status after UpdateGroup calls so the test fails immediately on API errors rather than relying on the subsequent Eventually timeout. --- test/s3/iam/s3_iam_group_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/s3/iam/s3_iam_group_test.go b/test/s3/iam/s3_iam_group_test.go index 343e0c6b6..e5b05cc06 100644 --- a/test/s3/iam/s3_iam_group_test.go +++ b/test/s3/iam/s3_iam_group_test.go @@ -520,6 +520,7 @@ func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { }) 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 { @@ -538,6 +539,7 @@ func TestIAMGroupDisabledPolicyEnforcement(t *testing.T) { }) 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 { From dfa4a37b966c4d7954ec319bd93b0a096932ea7e Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 00:32:02 -0700 Subject: [PATCH 63/72] fix: trim whitespace from group name in filer store operations Trim leading/trailing whitespace from group.Name before validation in CreateGroup and UpdateGroup to prevent whitespace-only filenames. Also merge groups by name during multi-file load to prevent duplicates. --- weed/credential/filer_etc/filer_etc_group.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/weed/credential/filer_etc/filer_etc_group.go b/weed/credential/filer_etc/filer_etc_group.go index 624a42a5a..22ae6e230 100644 --- a/weed/credential/filer_etc/filer_etc_group.go +++ b/weed/credential/filer_etc/filer_etc_group.go @@ -101,6 +101,9 @@ func (store *FilerEtcStore) deleteGroupFile(ctx context.Context, groupName strin } 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") } @@ -162,6 +165,9 @@ func (store *FilerEtcStore) ListGroups(ctx context.Context) ([]string, error) { } 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") } From cea1f4a3e8e05c69d1aa9dbc56c85eed1a3addd8 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 00:32:09 -0700 Subject: [PATCH 64/72] fix: add nil/empty group validation in gRPC store Guard CreateGroup and UpdateGroup against nil group or empty name to prevent panics and invalid persistence. --- weed/credential/grpc/grpc_group.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/weed/credential/grpc/grpc_group.go b/weed/credential/grpc/grpc_group.go index 394dc13ca..be0c155db 100644 --- a/weed/credential/grpc/grpc_group.go +++ b/weed/credential/grpc/grpc_group.go @@ -2,6 +2,7 @@ package grpc import ( "context" + "fmt" "github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" @@ -13,6 +14,9 @@ import ( // 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 @@ -66,6 +70,9 @@ func (store *IamGrpcStore) ListGroups(ctx context.Context) ([]string, error) { } 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 From 42f8495f59297585f3d3ae55e9b7e3f16b203a9a Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 00:32:14 -0700 Subject: [PATCH 65/72] fix: add nil/empty group validation in postgres store Guard CreateGroup and UpdateGroup against nil group or empty name to prevent panics from nil member access and empty-name row inserts. --- weed/credential/postgres/postgres_group.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/weed/credential/postgres/postgres_group.go b/weed/credential/postgres/postgres_group.go index 0cf361ab6..dca8c6817 100644 --- a/weed/credential/postgres/postgres_group.go +++ b/weed/credential/postgres/postgres_group.go @@ -13,6 +13,9 @@ import ( ) 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) @@ -95,6 +98,9 @@ func (store *PostgresStore) ListGroups(ctx context.Context) ([]string, error) { } 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) From d3efe5dde9efeec2955f58c4ee4f5f227d35c60a Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 00:32:19 -0700 Subject: [PATCH 66/72] fix: add name collision check in embedded IAM UpdateUser The embedded IAM handler renamed users without checking if the target name already existed, unlike the standalone handler. --- weed/s3api/s3api_embedded_iam.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 671de5853..941cc7cfd 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -349,6 +349,14 @@ func (e *EmbeddedIamApi) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values url userName := values.Get("UserName") newUserName := values.Get("NewUserName") if newUserName != "" { + // Check for name collision before renaming + if newUserName != userName { + for _, ident := range s3cfg.Identities { + if ident.Name == newUserName { + return resp, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("user %s already exists", newUserName)} + } + } + } for _, ident := range s3cfg.Identities { if userName == ident.Name { ident.Name = newUserName From 5db6687f3c8fc5778aba8530c7bedf7cb5e3324d Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 00:32:24 -0700 Subject: [PATCH 67/72] fix: add ErrGroupNotEmpty sentinel and map to HTTP 409 AdminServer.DeleteGroup now wraps conflict errors with ErrGroupNotEmpty, and groupErrorToHTTPStatus maps it to 409 Conflict instead of 500. --- weed/admin/dash/group_management.go | 4 ++-- weed/admin/handlers/group_handlers.go | 3 +++ weed/credential/credential_store.go | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/weed/admin/dash/group_management.go b/weed/admin/dash/group_management.go index 155027e78..1b6259658 100644 --- a/weed/admin/dash/group_management.go +++ b/weed/admin/dash/group_management.go @@ -108,10 +108,10 @@ func (s *AdminServer) DeleteGroup(ctx context.Context, name string) error { 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)", name, len(g.Members)) + 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)", name, len(g.PolicyNames)) + 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) diff --git a/weed/admin/handlers/group_handlers.go b/weed/admin/handlers/group_handlers.go index cd2aa38fb..50e65ac73 100644 --- a/weed/admin/handlers/group_handlers.go +++ b/weed/admin/handlers/group_handlers.go @@ -33,6 +33,9 @@ func groupErrorToHTTPStatus(err error) int { if errors.Is(err, credential.ErrPolicyNotFound) { return http.StatusNotFound } + if errors.Is(err, credential.ErrGroupNotEmpty) { + return http.StatusConflict + } return http.StatusInternalServerError } diff --git a/weed/credential/credential_store.go b/weed/credential/credential_store.go index f08f84540..f7972e78b 100644 --- a/weed/credential/credential_store.go +++ b/weed/credential/credential_store.go @@ -20,6 +20,7 @@ var ( ErrPolicyNotAttached = errors.New("policy not attached to user") ErrGroupNotFound = errors.New("group not found") ErrGroupAlreadyExists = errors.New("group already exists") + ErrGroupNotEmpty = errors.New("group is not empty") ErrUserNotInGroup = errors.New("user is not a member of the group") ) From e814b23b4866d03be8e4b869538dbc25a284b6c0 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 01:23:43 -0700 Subject: [PATCH 68/72] fix: use appropriate error message in GetGroupDetails based on status Return "Group not found" only for 404, use "Failed to retrieve group" for other error statuses instead of always saying "Group not found". --- weed/admin/handlers/group_handlers.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/weed/admin/handlers/group_handlers.go b/weed/admin/handlers/group_handlers.go index 50e65ac73..2b2c3ef32 100644 --- a/weed/admin/handlers/group_handlers.go +++ b/weed/admin/handlers/group_handlers.go @@ -97,7 +97,12 @@ func (h *GroupHandlers) GetGroupDetails(w http.ResponseWriter, r *http.Request) group, err := h.adminServer.GetGroupDetails(r.Context(), name) if err != nil { glog.Errorf("Failed to get group details: %v", err) - writeJSONError(w, groupErrorToHTTPStatus(err), "Group not found") + 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) From 56773ebf186157365037e54c204ad3ab0a9229b3 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 01:23:48 -0700 Subject: [PATCH 69/72] fix: use backend-normalized group.Name in CreateGroup response After credentialManager.CreateGroup may normalize the name (e.g., trim whitespace), use group.Name instead of the raw input for the returned GroupData to ensure consistency. --- weed/admin/dash/group_management.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/weed/admin/dash/group_management.go b/weed/admin/dash/group_management.go index 1b6259658..dda8f7f6d 100644 --- a/weed/admin/dash/group_management.go +++ b/weed/admin/dash/group_management.go @@ -91,9 +91,9 @@ func (s *AdminServer) CreateGroup(ctx context.Context, name string) (*GroupData, 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", name) + glog.V(1).Infof("Created group %s", group.Name) return &GroupData{ - Name: name, + Name: group.Name, Status: "enabled", }, nil } From 0896ff6fa4ce61792a0f883bf58066d2415779be Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 01:23:53 -0700 Subject: [PATCH 70/72] fix: add nil/empty group validation in memory store Guard CreateGroup and UpdateGroup against nil group or empty name to prevent panics from nil pointer dereference on map access. --- weed/credential/memory/memory_group.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/weed/credential/memory/memory_group.go b/weed/credential/memory/memory_group.go index 180ce51c5..b9e1ca067 100644 --- a/weed/credential/memory/memory_group.go +++ b/weed/credential/memory/memory_group.go @@ -2,6 +2,7 @@ package memory import ( "context" + "fmt" "github.com/seaweedfs/seaweedfs/weed/credential" "github.com/seaweedfs/seaweedfs/weed/pb/iam_pb" @@ -28,6 +29,9 @@ func cloneGroup(g *iam_pb.Group) *iam_pb.Group { } 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() @@ -71,6 +75,9 @@ func (store *MemoryStore) ListGroups(ctx context.Context) ([]string, error) { } 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() From f8c06f3994a56bd24528ca72e2805e367683d13b Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 01:23:58 -0700 Subject: [PATCH 71/72] fix: reorder embedded IAM UpdateUser to verify source first Find the source identity before checking for collisions, matching the standalone handler's logic. Previously a non-existent user renamed to an existing name would get EntityAlreadyExists instead of NoSuchEntity. --- weed/s3api/s3api_embedded_iam.go | 71 ++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 941cc7cfd..0d558ae0f 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -348,40 +348,51 @@ func (e *EmbeddedIamApi) UpdateUser(s3cfg *iam_pb.S3ApiConfiguration, values url resp := &iamUpdateUserResponse{} userName := values.Get("UserName") newUserName := values.Get("NewUserName") - if newUserName != "" { - // Check for name collision before renaming - if newUserName != userName { - for _, ident := range s3cfg.Identities { - if ident.Name == newUserName { - return resp, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("user %s already exists", newUserName)} - } - } + if newUserName == "" { + return resp, nil + } + + // Find the source identity first + var sourceIdent *iam_pb.Identity + for _, ident := range s3cfg.Identities { + if ident.Name == userName { + sourceIdent = ident + break } - for _, ident := range s3cfg.Identities { - if userName == ident.Name { - ident.Name = newUserName - // Update group membership references - for _, g := range s3cfg.Groups { - for j, m := range g.Members { - if m == userName { - g.Members[j] = newUserName - break - } - } - } - // Update service account parent references - for _, sa := range s3cfg.ServiceAccounts { - if sa.ParentUser == userName { - sa.ParentUser = newUserName - } - } - return resp, nil + } + if sourceIdent == nil { + return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)} + } + + // No-op if renaming to the same name + if newUserName == userName { + return resp, nil + } + + // Check for name collision before renaming + for _, ident := range s3cfg.Identities { + if ident.Name == newUserName { + return resp, &iamError{Code: iam.ErrCodeEntityAlreadyExistsException, Error: fmt.Errorf("user %s already exists", newUserName)} + } + } + + sourceIdent.Name = newUserName + // Update group membership references + for _, g := range s3cfg.Groups { + for j, m := range g.Members { + if m == userName { + g.Members[j] = newUserName + break } } - } else { - return resp, nil } - return resp, &iamError{Code: iam.ErrCodeNoSuchEntityException, Error: fmt.Errorf(iamUserDoesNotExist, userName)} + // Update service account parent references + for _, sa := range s3cfg.ServiceAccounts { + if sa.ParentUser == userName { + sa.ParentUser = newUserName + } + } + return resp, nil } // CreateAccessKey creates an access key for a user. From db5751f37038d83f7dfc93f2179c7fa339000978 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 9 Mar 2026 01:24:03 -0700 Subject: [PATCH 72/72] fix: handle same-directory renames in metadata subscription Replay a delete event for the old entry name during same-directory renames so handlers like onBucketMetadataChange can clean up stale state for the old name. --- weed/s3api/auth_credentials_subscribe.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/weed/s3api/auth_credentials_subscribe.go b/weed/s3api/auth_credentials_subscribe.go index 964ee4822..e0eb15554 100644 --- a/weed/s3api/auth_credentials_subscribe.go +++ b/weed/s3api/auth_credentials_subscribe.go @@ -33,13 +33,22 @@ func (s3a *S3ApiServer) subscribeMetaEvents(clientName string, lastTsNs int64, p _ = s3a.onIamConfigChange(dir, message.OldEntry, message.NewEntry) _ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, message.NewEntry) - // For moves across directories, also notify handlers about the source directory + // For moves across directories, replay a delete event for the source directory if message.NewParentPath != "" && resp.Directory != message.NewParentPath { _ = s3a.onBucketMetadataChange(resp.Directory, message.OldEntry, nil) _ = s3a.onIamConfigChange(resp.Directory, message.OldEntry, nil) _ = s3a.onCircuitBreakerConfigChange(resp.Directory, message.OldEntry, nil) } + // For same-directory renames, replay a delete event for the old name + // so handlers can clean up stale state (e.g., old bucket names) + if message.OldEntry != nil && message.NewEntry != nil && + (message.NewParentPath == "" || message.NewParentPath == resp.Directory) && + message.OldEntry.Name != message.NewEntry.Name { + _ = s3a.onBucketMetadataChange(dir, message.OldEntry, nil) + _ = s3a.onCircuitBreakerConfigChange(dir, message.OldEntry, nil) + } + return nil }