feat: Introduce KickedPending and BannedPending states (#3948)

* feat: introduce KickedPending state for community members

* feat: tests for ban/unban pending states

* fix: remove pending And banned members from public serialization

* feat: add check for banning and kicking privileged users

* fix: process only first event when obtaining PendingAndBannedMembers

* fix: review fixes

* fix: proper conditions for kicking and banning checks

* Fix: fix tests after rebase
This commit is contained in:
Mikhail Rogachev 2023-10-04 23:47:22 +03:00 committed by GitHub
parent eb6ed5ebae
commit a17ee052fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 276 additions and 62 deletions

View file

@ -106,6 +106,15 @@ type CommunityTag struct {
Emoji string `json:"emoji"`
}
type CommunityMemberState uint8
const (
CommunityMemberBanned CommunityMemberState = iota
CommunityMemberBanPending
CommunityMemberUnbanPending
CommunityMemberKickPending
)
func (o *Community) MarshalPublicAPIJSON() ([]byte, error) {
if o.config.MemberIdentity == nil {
return nil, errors.New("member identity not set")
@ -127,7 +136,6 @@ func (o *Community) MarshalPublicAPIJSON() ([]byte, error) {
Link string `json:"link"`
CommunityAdminSettings CommunityAdminSettings `json:"adminSettings"`
Encrypted bool `json:"encrypted"`
BanList []string `json:"banList"`
TokenPermissions map[string]*CommunityTokenPermission `json:"tokenPermissions"`
CommunityTokensMetadata []*protobuf.CommunityTokenMetadata `json:"communityTokensMetadata"`
ActiveMembersCount uint64 `json:"activeMembersCount"`
@ -173,7 +181,6 @@ func (o *Community) MarshalPublicAPIJSON() ([]byte, error) {
communityItem.Link = fmt.Sprintf("https://join.status.im/c/0x%x", o.ID())
communityItem.IntroMessage = o.config.CommunityDescription.IntroMessage
communityItem.OutroMessage = o.config.CommunityDescription.OutroMessage
communityItem.BanList = o.config.CommunityDescription.BanList
communityItem.CommunityTokensMetadata = o.config.CommunityDescription.CommunityTokensMetadata
communityItem.ActiveMembersCount = o.config.CommunityDescription.ActiveMembersCount
@ -234,7 +241,7 @@ func (o *Community) MarshalJSON() ([]byte, error) {
MuteTill time.Time `json:"muteTill,omitempty"`
CommunityAdminSettings CommunityAdminSettings `json:"adminSettings"`
Encrypted bool `json:"encrypted"`
BanList []string `json:"banList"`
PendingAndBannedMembers map[string]CommunityMemberState `json:"pendingAndBannedMembers"`
TokenPermissions map[string]*CommunityTokenPermission `json:"tokenPermissions"`
CommunityTokensMetadata []*protobuf.CommunityTokenMetadata `json:"communityTokensMetadata"`
ActiveMembersCount uint64 `json:"activeMembersCount"`
@ -288,11 +295,11 @@ func (o *Community) MarshalJSON() ([]byte, error) {
communityItem.Chats[id] = chat
}
communityItem.TokenPermissions = o.tokenPermissions()
communityItem.PendingAndBannedMembers = o.PendingAndBannedMembers()
communityItem.Members = o.config.CommunityDescription.Members
communityItem.Permissions = o.config.CommunityDescription.Permissions
communityItem.IntroMessage = o.config.CommunityDescription.IntroMessage
communityItem.OutroMessage = o.config.CommunityDescription.OutroMessage
communityItem.BanList = o.config.CommunityDescription.BanList
communityItem.CommunityTokensMetadata = o.config.CommunityDescription.CommunityTokensMetadata
communityItem.ActiveMembersCount = o.config.CommunityDescription.ActiveMembersCount
@ -305,7 +312,6 @@ func (o *Community) MarshalJSON() ([]byte, error) {
communityItem.Images = make(map[string]images.IdentityImage)
}
communityItem.Images[t] = images.IdentityImage{Name: t, Payload: i.Payload}
}
}
@ -717,9 +723,8 @@ func (o *Community) RemoveUserFromOrg(pk *ecdsa.PublicKey) (*protobuf.CommunityD
return nil, ErrCannotRemoveOwnerOrAdmin
}
o.removeMemberFromOrg(pk)
if o.IsControlNode() {
o.removeMemberFromOrg(pk)
o.increaseClock()
} else {
err := o.addNewCommunityEvent(o.ToKickCommunityMemberCommunityEvent(common.PubkeyToHex(pk)))
@ -761,9 +766,8 @@ func (o *Community) UnbanUserFromCommunity(pk *ecdsa.PublicKey) (*protobuf.Commu
return nil, ErrNotAuthorized
}
o.unbanUserFromCommunity(pk)
if o.IsControlNode() {
o.unbanUserFromCommunity(pk)
o.increaseClock()
} else {
err := o.addNewCommunityEvent(o.ToUnbanCommunityMemberCommunityEvent(common.PubkeyToHex(pk)))
@ -787,9 +791,8 @@ func (o *Community) BanUserFromCommunity(pk *ecdsa.PublicKey) (*protobuf.Communi
return nil, ErrCannotBanOwnerOrAdmin
}
o.banUserFromCommunity(pk)
if o.IsControlNode() {
o.banUserFromCommunity(pk)
o.increaseClock()
} else {
err := o.addNewCommunityEvent(o.ToBanCommunityMemberCommunityEvent(common.PubkeyToHex(pk)))
@ -1440,6 +1443,40 @@ func (o *Community) tokenPermissions() map[string]*CommunityTokenPermission {
return result
}
func (o *Community) PendingAndBannedMembers() map[string]CommunityMemberState {
result := make(map[string]CommunityMemberState)
// Non-privileged members should not see pending and banned members
if o.config.EventsData == nil || !o.IsPrivilegedMember(o.MemberIdentity()) {
return result
}
for _, bannedMemberID := range o.config.CommunityDescription.BanList {
result[bannedMemberID] = CommunityMemberBanned
}
processedEvents := make(map[string]bool)
for _, event := range o.config.EventsData.Events {
if processedEvents[event.MemberToAction] {
continue
}
switch event.Type {
case protobuf.CommunityEvent_COMMUNITY_MEMBER_KICK:
result[event.MemberToAction] = CommunityMemberKickPending
case protobuf.CommunityEvent_COMMUNITY_MEMBER_BAN:
result[event.MemberToAction] = CommunityMemberBanPending
case protobuf.CommunityEvent_COMMUNITY_MEMBER_UNBAN:
result[event.MemberToAction] = CommunityMemberUnbanPending
default:
continue
}
processedEvents[event.MemberToAction] = true
}
return result
}
func (o *Community) TokenPermissions() map[string]*CommunityTokenPermission {
o.mutex.Lock()
defer o.mutex.Unlock()
@ -2233,12 +2270,21 @@ func (o *Community) ValidateEvent(event *CommunityEvent, signer *ecdsa.PublicKey
return err
}
member := o.getMember(signer)
if member == nil {
eventSender := o.getMember(signer)
if eventSender == nil {
return ErrMemberNotFound
}
if !RolesAuthorizedToPerformEvent(member.Roles, event) {
eventTargetRoles := []protobuf.CommunityMember_Roles{}
eventTargetPk, err := common.HexToPubkey(event.MemberToAction)
if err == nil {
eventTarget := o.getMember(eventTargetPk)
if eventTarget != nil {
eventTargetRoles = eventTarget.Roles
}
}
if !RolesAuthorizedToPerformEvent(eventSender.Roles, eventTargetRoles, event) {
return ErrNotAuthorized
}

View file

@ -298,35 +298,29 @@ func (o *Community) updateCommunityDescriptionByCommunityEvent(communityEvent Co
}
case protobuf.CommunityEvent_COMMUNITY_MEMBER_KICK:
pk, err := common.HexToPubkey(communityEvent.MemberToAction)
if err != nil {
return err
if o.IsControlNode() {
pk, err := common.HexToPubkey(communityEvent.MemberToAction)
if err != nil {
return err
}
o.removeMemberFromOrg(pk)
}
if !o.IsControlNode() && o.IsPrivilegedMember(pk) {
return errors.New("attempt to kick an control node or privileged user from non-control node side")
}
o.removeMemberFromOrg(pk)
case protobuf.CommunityEvent_COMMUNITY_MEMBER_BAN:
pk, err := common.HexToPubkey(communityEvent.MemberToAction)
if err != nil {
return err
if o.IsControlNode() {
pk, err := common.HexToPubkey(communityEvent.MemberToAction)
if err != nil {
return err
}
o.banUserFromCommunity(pk)
}
if !o.IsControlNode() && o.IsPrivilegedMember(pk) {
return errors.New("attempt to ban an control node or privileged user from non-control node side")
}
o.banUserFromCommunity(pk)
case protobuf.CommunityEvent_COMMUNITY_MEMBER_UNBAN:
pk, err := common.HexToPubkey(communityEvent.MemberToAction)
if err != nil {
return err
if o.IsControlNode() {
pk, err := common.HexToPubkey(communityEvent.MemberToAction)
if err != nil {
return err
}
o.unbanUserFromCommunity(pk)
}
o.unbanUserFromCommunity(pk)
case protobuf.CommunityEvent_COMMUNITY_TOKEN_ADD:
o.config.CommunityDescription.CommunityTokensMetadata = append(o.config.CommunityDescription.CommunityTokensMetadata, communityEvent.TokenMetadata)
}

View file

@ -1,6 +1,10 @@
package communities
import "github.com/status-im/status-go/protocol/protobuf"
import (
"golang.org/x/exp/slices"
"github.com/status-im/status-go/protocol/protobuf"
)
var adminAuthorizedEventTypes = []protobuf.CommunityEvent_EventType{
protobuf.CommunityEvent_COMMUNITY_EDIT,
@ -56,11 +60,8 @@ var rolesToAuthorizedPermissionTypes = map[protobuf.CommunityMember_Roles][]prot
func canRolesPerformEvent(roles []protobuf.CommunityMember_Roles, eventType protobuf.CommunityEvent_EventType) bool {
for _, role := range roles {
authorizedEventTypes := rolesToAuthorizedEventTypes[role]
for _, authorizedEventType := range authorizedEventTypes {
if authorizedEventType == eventType {
return true
}
if slices.Contains(rolesToAuthorizedEventTypes[role], eventType) {
return true
}
}
return false
@ -68,24 +69,51 @@ func canRolesPerformEvent(roles []protobuf.CommunityMember_Roles, eventType prot
func canRolesModifyPermission(roles []protobuf.CommunityMember_Roles, permissionType protobuf.CommunityTokenPermission_Type) bool {
for _, role := range roles {
authorizedPermissionTypes := rolesToAuthorizedPermissionTypes[role]
for _, authorizedPermissionType := range authorizedPermissionTypes {
if authorizedPermissionType == permissionType {
return true
}
if slices.Contains(rolesToAuthorizedPermissionTypes[role], permissionType) {
return true
}
}
return false
}
func RolesAuthorizedToPerformEvent(roles []protobuf.CommunityMember_Roles, event *CommunityEvent) bool {
if !canRolesPerformEvent(roles, event.Type) {
func canRolesKickOrBanMember(senderRoles []protobuf.CommunityMember_Roles, memberRoles []protobuf.CommunityMember_Roles) bool {
// Owner can kick everyone
if slices.Contains(senderRoles, protobuf.CommunityMember_ROLE_OWNER) {
return true
}
// TokenMaster can kick normal members and admins
if (slices.Contains(senderRoles, protobuf.CommunityMember_ROLE_TOKEN_MASTER)) &&
!(slices.Contains(memberRoles, protobuf.CommunityMember_ROLE_TOKEN_MASTER) ||
slices.Contains(memberRoles, protobuf.CommunityMember_ROLE_OWNER)) {
return true
}
// Admins can kick normal members
if (slices.Contains(senderRoles, protobuf.CommunityMember_ROLE_ADMIN)) &&
!(slices.Contains(memberRoles, protobuf.CommunityMember_ROLE_ADMIN) ||
slices.Contains(memberRoles, protobuf.CommunityMember_ROLE_TOKEN_MASTER) ||
slices.Contains(memberRoles, protobuf.CommunityMember_ROLE_OWNER)) {
return true
}
// Normal members can't kick anyone
return false
}
func RolesAuthorizedToPerformEvent(senderRoles []protobuf.CommunityMember_Roles, memberRoles []protobuf.CommunityMember_Roles, event *CommunityEvent) bool {
if !canRolesPerformEvent(senderRoles, event.Type) {
return false
}
if event.Type == protobuf.CommunityEvent_COMMUNITY_MEMBER_TOKEN_PERMISSION_CHANGE ||
event.Type == protobuf.CommunityEvent_COMMUNITY_MEMBER_TOKEN_PERMISSION_DELETE {
return canRolesModifyPermission(roles, event.TokenPermission.Type)
return canRolesModifyPermission(senderRoles, event.TokenPermission.Type)
}
if event.Type == protobuf.CommunityEvent_COMMUNITY_MEMBER_BAN ||
event.Type == protobuf.CommunityEvent_COMMUNITY_MEMBER_KICK {
return canRolesKickOrBanMember(senderRoles, memberRoles)
}
return true

View file

@ -624,6 +624,10 @@ func kickMember(base CommunityEventsTestsInterface, communityID types.HexBytes,
return errors.New("alice was not kicked")
}
if len(modifiedCommmunity.PendingAndBannedMembers()) > 0 {
return errors.New("alice was kicked and should not be presented in the pending list")
}
return nil
}
@ -634,12 +638,62 @@ func kickMember(base CommunityEventsTestsInterface, communityID types.HexBytes,
s := base.GetSuite()
s.Require().NoError(err)
s.Require().Nil(checkKicked(response))
checkClientsReceivedAdminEvent(base, checkKicked)
// 1. event sender should get pending state for kicked member
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(communityID))
s.Require().NoError(err)
s.Require().True(modifiedCommmunity.HasMember(&base.GetMember().identity.PublicKey))
s.Require().Equal(communities.CommunityMemberKickPending, modifiedCommmunity.PendingAndBannedMembers()[pubkey])
// 2. wait for event as a sender
waitOnMessengerResponse(s, func(response *MessengerResponse) error {
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(communityID))
if err != nil {
return err
}
if !modifiedCommmunity.HasMember(&base.GetMember().identity.PublicKey) {
return errors.New("alice should not be not kicked (yet)")
}
if modifiedCommmunity.PendingAndBannedMembers()[pubkey] != communities.CommunityMemberKickPending {
return errors.New("alice should be in the pending state")
}
return nil
}, base.GetEventSender())
// 3. wait for event as the community member and check we are still until control node gets it
waitOnMessengerResponse(s, func(response *MessengerResponse) error {
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(communityID))
if err != nil {
return err
}
if !modifiedCommmunity.HasMember(&base.GetMember().identity.PublicKey) {
return errors.New("alice should not be not kicked (yet)")
}
if len(modifiedCommmunity.PendingAndBannedMembers()) > 0 {
return errors.New("alice should not know about banned and pending members")
}
return nil
}, base.GetMember())
// 4. control node should handle event and actually kick member
waitOnMessengerResponse(s, checkKicked, base.GetControlNode())
// 5. event sender get removed member
waitOnMessengerResponse(s, checkKicked, base.GetEventSender())
// 6. member should be notified about actual removal
waitOnMessengerResponse(s, checkKicked, base.GetMember())
}
func banMember(base CommunityEventsTestsInterface, banRequest *requests.BanUserFromCommunity) {
pubkey := common.PubkeyToHex(&base.GetMember().identity.PublicKey)
checkBanned := func(response *MessengerResponse) error {
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(banRequest.CommunityID))
if err != nil {
@ -654,6 +708,10 @@ func banMember(base CommunityEventsTestsInterface, banRequest *requests.BanUserF
return errors.New("alice was not added to the banned list")
}
if modifiedCommmunity.PendingAndBannedMembers()[pubkey] != communities.CommunityMemberBanned {
return errors.New("alice should be in the pending state")
}
return nil
}
@ -661,12 +719,62 @@ func banMember(base CommunityEventsTestsInterface, banRequest *requests.BanUserF
s := base.GetSuite()
s.Require().NoError(err)
s.Require().Nil(checkBanned(response))
checkClientsReceivedAdminEvent(base, checkBanned)
// 1. event sender should get pending state for ban member
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(banRequest.CommunityID))
s.Require().NoError(err)
s.Require().True(modifiedCommmunity.HasMember(&base.GetMember().identity.PublicKey))
s.Require().Equal(communities.CommunityMemberBanPending, modifiedCommmunity.PendingAndBannedMembers()[pubkey])
// 2. wait for event as a sender
waitOnMessengerResponse(s, func(response *MessengerResponse) error {
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(banRequest.CommunityID))
if err != nil {
return err
}
if !modifiedCommmunity.HasMember(&base.GetMember().identity.PublicKey) {
return errors.New("alice should not be not banned (yet)")
}
if modifiedCommmunity.PendingAndBannedMembers()[pubkey] != communities.CommunityMemberBanPending {
return errors.New("alice should be in the pending state")
}
return nil
}, base.GetEventSender())
// 3. wait for event as the community member and check we are still until control node gets it
waitOnMessengerResponse(s, func(response *MessengerResponse) error {
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(banRequest.CommunityID))
if err != nil {
return err
}
if !modifiedCommmunity.HasMember(&base.GetMember().identity.PublicKey) {
return errors.New("alice should not be not banned (yet)")
}
if len(modifiedCommmunity.PendingAndBannedMembers()) > 0 {
return errors.New("alice should not know about banned and pending members")
}
return nil
}, base.GetMember())
// 4. control node should handle event and actually ban member
waitOnMessengerResponse(s, checkBanned, base.GetControlNode())
// 5. event sender get banned member
waitOnMessengerResponse(s, checkBanned, base.GetEventSender())
// 6. member should be notified about actual removal
waitOnMessengerResponse(s, checkBanned, base.GetMember())
}
func unbanMember(base CommunityEventsTestsInterface, unbanRequest *requests.UnbanUserFromCommunity) {
pubkey := common.PubkeyToHex(&base.GetMember().identity.PublicKey)
checkUnbanned := func(response *MessengerResponse) error {
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(unbanRequest.CommunityID))
if err != nil {
@ -677,6 +785,10 @@ func unbanMember(base CommunityEventsTestsInterface, unbanRequest *requests.Unba
return errors.New("alice was not unbanned")
}
if modifiedCommmunity.PendingAndBannedMembers()[pubkey] != communities.CommunityMemberBanned {
return errors.New("alice should be in the pending state")
}
return nil
}
@ -684,14 +796,48 @@ func unbanMember(base CommunityEventsTestsInterface, unbanRequest *requests.Unba
s := base.GetSuite()
s.Require().NoError(err)
s.Require().Nil(checkUnbanned(response))
_, err = WaitOnMessengerResponse(
base.GetControlNode(),
func(r *MessengerResponse) bool { return checkUnbanned(r) == nil },
"MessengerResponse data not received",
)
// 1. event sender should get pending state for unban member
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(unbanRequest.CommunityID))
s.Require().NoError(err)
s.Require().Equal(communities.CommunityMemberUnbanPending, modifiedCommmunity.PendingAndBannedMembers()[pubkey])
// 2. wait for event as a sender
waitOnMessengerResponse(s, func(response *MessengerResponse) error {
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(unbanRequest.CommunityID))
if err != nil {
return err
}
if modifiedCommmunity.PendingAndBannedMembers()[pubkey] != communities.CommunityMemberUnbanPending {
return errors.New("alice should be in the pending state")
}
return nil
}, base.GetEventSender())
// 3. wait for event as the community member and check we are still until control node gets it
waitOnMessengerResponse(s, func(response *MessengerResponse) error {
modifiedCommmunity, err := getModifiedCommunity(response, types.EncodeHex(unbanRequest.CommunityID))
if err != nil {
return err
}
if len(modifiedCommmunity.PendingAndBannedMembers()) > 0 {
return errors.New("alice should not know about banned and pending members")
}
return nil
}, base.GetMember())
// 4. control node should handle event and actually unban member
waitOnMessengerResponse(s, checkUnbanned, base.GetControlNode())
// 5. event sender get removed member
waitOnMessengerResponse(s, checkUnbanned, base.GetEventSender())
// 6. member should be notified about actual removal
waitOnMessengerResponse(s, checkUnbanned, base.GetMember())
}
func controlNodeSendMessage(base CommunityEventsTestsInterface, inputMessage *common.Message) string {