Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow owners, admins and moderators to edit groups #9064

Open
wants to merge 6 commits into
base: memberships-relationship
Choose a base branch
from

Conversation

seanh
Copy link
Contributor

@seanh seanh commented Nov 1, 2024

Permit owners, admins and moderators, rather than the group's creator, to edit a group. The creator can't edit the group unless they happen to also be an owner, admin or moderator (creators of groups will be owners unless they've subsequently left the group or changed roles).

This allows owners, admins and moderators to call the edit-group API, to see the "edit group" link on the group's page, and to view the edit-group page.

Testing

  1. Create a group and visit the group's page. As the owner of the group you should see the Edit group link. Click on the link and check that you can edit the group.

  2. Log out and visit the group's edit page to test that an unauthenticated user can't edit a group: you should get a 404.

  3. Log in as another user and visit the group's edit page to check that a non-member can't edit a group.

  4. Join the group and visit the group's edit page to check that a plain member can't edit a group.

  5. Promote the user to moderator or admin and check that now they can edit the group.

    This has to be done in SQL, for example:

    update user_group set roles = '["admin"]'::jsonb where id = 42;

Change `factories.Group()` to *not* automatically add the group's
creator as a member of the group.

Future commits need to replace the `group.members` relation with a
`group.memberships` relation (which is a list of `GroupMembership`'s
rather than a list of `User`'s). See
<#9047>. This is necessary because
`GroupMembership`'s will in future have additional attributes (e.g.
`roles`) and to add a user to a group with a particular role it'll be
necessary to append a `GroupMembership` with that role to
`group.memberships`, it's not enough to append a `User` to
`group.members` because the role is an attribute of the membership not
an attribute of the user, so we need to actually create a
`GroupMembership` with the desired role and append that.

With this change it'll no longer be possible for `factories.Group`'s
`add_creator_as_member()` to add the creator as a member. For example
this kind of thing won't work:

    @factory.post_generation
    def add_creator_as_member(  # pylint:disable=no-self-argument
        obj, _create, _extracted, **_kwargs
    ):
        if (
            obj.creator
            and obj.creator
            not in obj.members
        ):
            obj.memberships.append(
                models.GroupMembership(
                    group=obj,
                    user=obj.creator,
                    role="owner
                )
            )

The problem is that the `GroupMembership` that's been appended will not
have been added to the DB session, which causes this SQLAlchemy error:
https://docs.sqlalchemy.org/en/20/errors.html#object-is-being-merged-into-a-session-along-the-backref-cascade

Or alternatively you get a `NotNullViolation`, depending.

Nor can `factories.Group.add_creator_as_member()` simply add the
`GroupMembership` to the DB session: it doesn't have access to the DB
session (and this wouldn't necessarily get around `NotNullViolation`'s
anyway).

Removing this feels like a good direction to me because
`add_creator_as_member()` seems too clever for a test factory, and my
experience with test factories is that having them do extra things like
this automatically usually ends up creating problems and it's better to
keep the factories simpler and just make certain tests do more work.

There looks to have been a bunch of tests that were implicitly or
explicitly relying on the fact that the factory adds the group's creator
as a member, even when this concept is irrelevant to the test at hand.
So I think removing this is a good thing.

The current behavior is also potentially confusing when you do something
like `factories.Group(members=[...])` and then it auto-generates a user
to be the group's `creator` and adds them to the group's members even
though that user wasn't in the members list that was passed in.
@seanh seanh requested a review from marcospri November 1, 2024 16:24
@seanh seanh force-pushed the new-permissions branch 2 times, most recently from 541aa42 to da675cf Compare November 1, 2024 16:47
Comment on lines +195 to +206
def get_members(self, role: GroupMembershipRoles | None = None) -> tuple[User, ...]:
"""Return a tuple of this group's members."""
if role:
memberships = [
membership
for membership in self.memberships
if role in membership.roles
]
else:
memberships = self.memberships

return tuple(membership.user for membership in memberships)
Copy link
Contributor Author

@seanh seanh Nov 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered having three separate @property's moderators, admins and owners. These would be easier to use (just group.owners instead of group.get_members(GroupMembershipRoles.OWNER)). But you'd have to keep adding more @property's if we add more roles, and you can't add a property for the GroupMembershipRoles.MEMBER role because there's already a property called members (which returns all members regardless of role). With this method you can do group.get_members(GroupMembershipRoles.MEMBER) to get only the plain members, or group.get_members() to get all members regardless of role (or just access the group.members convenience property, which is the same as group.get_members()).

Comment on lines +53 to +55
[p.group_has_user_as_owner],
[p.group_has_user_as_admin],
[p.group_has_user_as_moderator],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that the group's creator is no longer permitted to edit the group, and instead the group's owners, admins and moderators are.

Comment on lines +146 to +166
def group_has_user_as_owner(identity, context):
return any(
owner.id == identity.user.id
for owner in context.group.get_members(GroupMembershipRoles.OWNER)
)


@requires(authenticated_user, group_found)
def group_has_user_as_admin(identity, context):
return any(
admin.id == identity.user.id
for admin in context.group.get_members(GroupMembershipRoles.ADMIN)
)


return any(user_group.id == context.group.id for user_group in identity.user.groups)
@requires(authenticated_user, group_found)
def group_has_user_as_moderator(identity, context):
return any(
moderator.id == identity.user.id
for moderator in context.group.get_members(GroupMembershipRoles.MODERATOR)
)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coding the three roles owner, admin and moderator here, but given how this "predicates" system works I don't see a choice without refactoring it (I don't think the permission map can pass arguments to predicates).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could read better as a service method

has_role_in_group(user, group, role) or is_member(user, group, role=None) or similar. It could also be written with less iteration and avoiding any N+1 query issues.

Comment on lines +169 to +171
@requires(authenticated_user, group_found)
def group_has_user_as_member(identity, context):
return any(member.id == identity.user.id for member in context.group.members)
Copy link
Contributor Author

@seanh seanh Nov 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rewrote the existing group_has_user_as_member() predicate to work by iterating over the group's members the same as the three new group_has_user_as_*() predicates that I added above, instead of iterating over the user's groups (which doesn't work now that we have to care about membership roles).

It's tempting to do for member in context.group.get_members(GroupMembershipRoles.MEMBER) here, following the same pattern as the other predicates, but that would change the behavior of this predicate to return only those members with the role "member" rather than returning all members regardless of role. (Unfortunately there are two different meanings of the word "member" in play here.)

Comment on lines -147 to -149
# With detached groups like we have with the websocket, this doesn't work
# as SQLAlchemy does not consider them equal:
# return context.group in identity.user.groups
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a distracting comment giving a commented-out alternative implementation of the line below it, plus a comment explaining why the commented-out version wouldn't work. I just removed the comment.

@seanh seanh force-pushed the new-permissions branch 2 times, most recently from 728ee44 to b953ba4 Compare November 1, 2024 17:17
Comment on lines -175 to -179
# Note we don't use the same literal objects here. It's important
# that we test based on the values not Python equality as the objects
# are detached in the WS and don't evaluate as equal
identity.user.groups = [factories.Group.build(id=i) for i in range(3)]
group_context.group = factories.Group.build(id=1 if matching else 100)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was written in a weird way that was coupled to the way the predicate was implemented (based on user.groups instead of group.members). Rewrote the test to work the same as the new tests that I added for the other predicates.

Permit owners, admins and moderators, rather than the group's creator,
to edit a group. The creator can't edit the group unless they happen to
also be an owner, admin or moderator (creators of groups will be owners
unless they've subsequently left the group or changed roles).

This allows owners, admins and moderators to call the edit-group API, to
see the "edit group" link on the group's page, and to view the
edit-group page.
It's not enough to just test the status code, you also have to test
_why_ that status code was returned. Otherwise the test could be passing
even though the code isn't returning the expected response.
Copy link
Member

@marcospri marcospri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, added a suggestion on how to potentially avoid N+1 query issues.

return tuple(membership.user for membership in self.memberships)
return self.get_members()

def get_members(self, role: GroupMembershipRoles | None = None) -> tuple[User, ...]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method could potentially make many queries to the DB without any explicit load strategies declared here.

Usually our go to solution here would be to have this in the service writing as one query returning the same tuple.

We might get away with it as this is scoped for one group only

Comment on lines +146 to +166
def group_has_user_as_owner(identity, context):
return any(
owner.id == identity.user.id
for owner in context.group.get_members(GroupMembershipRoles.OWNER)
)


@requires(authenticated_user, group_found)
def group_has_user_as_admin(identity, context):
return any(
admin.id == identity.user.id
for admin in context.group.get_members(GroupMembershipRoles.ADMIN)
)


return any(user_group.id == context.group.id for user_group in identity.user.groups)
@requires(authenticated_user, group_found)
def group_has_user_as_moderator(identity, context):
return any(
moderator.id == identity.user.id
for moderator in context.group.get_members(GroupMembershipRoles.MODERATOR)
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could read better as a service method

has_role_in_group(user, group, role) or is_member(user, group, role=None) or similar. It could also be written with less iteration and avoiding any N+1 query issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants