Skip to content

Commit

Permalink
Implement locking out participants (#2531)
Browse files Browse the repository at this point in the history
* Implement mixin prototype

* Finish and test mixin

* Allow locking out people with can_update

* Make and test participant json_upload changes

* Make changes for participant import

* Update merge_together and meeting_user actions

* Forbid locked out users from calling meeting actions

* Update wiki

* Make changes
  • Loading branch information
luisa-beerboom authored Aug 7, 2024
1 parent 55a51af commit b9d51b6
Show file tree
Hide file tree
Showing 30 changed files with 1,873 additions and 14 deletions.
2 changes: 2 additions & 0 deletions docs/actions/meeting_user.create.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
chat_message_ids: Id[];
vote_delegated_to_id: Id;
vote_delegations_from_ids: Id[];
locked_out: boolean;
// Group B
about_me: HTML;
Expand All @@ -31,6 +32,7 @@

## Action
The action creates a meeting_user item. `vote_delegated_to_id` and `vote_delegations_from_ids` have special checks, see user checks.
If `locked_out` is set, it checks against the present `user.can_manage` and all admin statuses and throws an error if any are present.

## Permissions
Group A: The request user needs `user.can_manage`.
Expand Down
4 changes: 3 additions & 1 deletion docs/actions/meeting_user.update.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
chat_message_ids: Id[];
vote_delegated_to_id: Id;
vote_delegations_from_ids: Id[];
locked_out: boolean;
// Group B
about_me: HTML;
Expand All @@ -36,4 +37,5 @@
```
## Internal action
Updates a meeting_user. `vote_delegated_to_id` and `vote_delegations_from_ids` has special checks, see user checks.
Updates a meeting_user. `vote_delegated_to_id` and `vote_delegations_from_ids` has special checks, see user checks.
The action checks, whether at the end the field `locked_out` will be set together with any of `user.can_manage` or any admin statuses on the updated meeting_user and throws an error if that is the case.
2 changes: 2 additions & 0 deletions docs/actions/participant.json_upload.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ The types noted below are the internal types after conversion in the backend. Se
is_present: boolean, // info: done or remove (missing field permission)
groups: string[], // info per item: done, warning, generated
saml_id: string, // unique saml_id, info: new, warning, error, done or remove (missing field permission)
locked_out: boolean, // info: done, error or remove (missing field permission)
}[],
}
```
Expand All @@ -34,6 +35,7 @@ See general user fields in [account.json_upload#user-matching](account.json_uplo
- `groups`: object with info "warning" for not found groups, "done" for a found group. If there is no group found at all, the default group will added automatically with state "generated".
- `vote_weight` doesn't allow 0 values
- `structure_level` will return `new` if it is not found, in such cases the structure level will be created in the import
- `locked_out` will be checked against corresponding (orga-, committee-, and meeting-) admin and `user.can_manage` permissions with field state `error` being set if both things would be there in the end result.
- All fields that could be removed by missing permission could have the state "remove" (will be
removed on import) or "done" (will be imported). See `info` note in payload above for affected
fields.
Expand Down
2 changes: 2 additions & 0 deletions docs/actions/user.create.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
vote_weight: decimal;
about_me: HTML;
comment: HTML;
locked_out: boolean;
structure_level_id: Id;
vote_delegated_to_id: Id;
Expand Down Expand Up @@ -59,6 +60,7 @@ Creates a user.
* The given `gender` must be present in `organization/genders`
* If `saml_id` is set in payload, there may be no `password` or `default_password` set or generated and `set_change_own_password` will be set to False.
* The `member_number` must be unique within all users.
* The action checks, whether at the end the field `locked_out` will be set together with any of `user.can_manage` or any admin statuses on the created user and throws an error if that is the case.

### Generate a username
If no username is given, it will be set from a given `saml_id`. Otherwise it is generated from `first_name` and `last_name`. Join all non-empty values from these two fields in the given order. If both fields are empty, raise an error, that one of the fields is required (see [OS3](https://github.com/OpenSlides/OpenSlides/blob/main/server/openslides/users/serializers.py#L90)). Remove all spaces from a generated username.
Expand Down
1 change: 1 addition & 0 deletions docs/actions/user.merge_together.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ The primary model is updated/re-created with the information from the secondary
- `motion_submitter_ids`, `personal_note_ids` and `speaker_ids` are create-merged
- other relation-lists are set to the union of their content among all selected users
- `comment`, `number`, `about_me`, `vote_weight`, `vote_delegated_to_id` are set to the value from the highest ranked model that has the field
- `locked_out` is set to whatever the primary model of the sub-merge has

#### Personal note merge
Equivalence is determined via equivalence of `content_object_id`
Expand Down
2 changes: 2 additions & 0 deletions docs/actions/user.update.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
vote_weight: decimal;
about_me: HTML;
comment: HTML;
locked_out: boolean;
structure_level_id: Id;
vote_delegated_to_id: Id;
Expand Down Expand Up @@ -67,6 +68,7 @@ Updates a user.
* Remove starting and trailing spaces from `username`, `first_name` and `last_name`
* The given `gender` must be present in `organization/genders`
* The `member_number` must be unique within all users.
* The action checks, whether at the end the field `locked_out` will be set together with any of `user.can_manage` or any admin statuses on the updated user and throws an error if that is the case.

Note: `is_present_in_meeting_ids` is not available in update, since there is no possibility to partially update this field. This can be done via [user.set_present](user.set_present.md).

Expand Down
5 changes: 4 additions & 1 deletion docs/presenters/get_user_related_models.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
motion_submitter_ids: Id[];
assignment_candidate_ids: Id[];
speaker_ids: Id[];
locked_out: boolean;
}]
}
}
Expand All @@ -30,7 +31,9 @@

It iterates over the given `user_ids`. For every id of `user_ids` all objects are searched which are associated with that id. This means that for every committee it is checked if the user (specified by the id) is a manager or member of the committee, and for every meeting if the user is listed as a speaker of any `agenda_item` or as a submitter of any `motion` or as a candidate of any `assignment`.
The result is a dictionary whose keys are the `user_ids`. The values are threefolded: `organization_management_level` contains the OML of the user. The two other values are arrays, one for the `committees` and one for the `meetings`. If a user is no member of any committee, then the `committees` array is empty and omitted. The same applies to the `meetings` array.
If a meeting has `locked_from_inside` set to true, `is_locked` will be true and `motion_submitter_ids`, `assignment_candidate_ids` and `speaker_ids` will be left out for this meeting, unless the calling user is in the meeting himself.
If a meeting has `locked_from_inside` set to true, `is_locked` will be true and `motion_submitter_ids`, `assignment_candidate_ids` and `speaker_ids`, `locked_out` will be left out for this meeting, unless the calling user is in the meeting himself.

To make the distinction clear: `is_locked` denominates that the entire meeting has been locked from inside, `locked_out` means that only the user has been locked out of the meeting.

Every committee is given by its name and id as well as the CML of the user (given by the `user_id`). Every meeting is given by its name, its id and its `is_active_in_organization_id` (to indicate if the meeting is archived).

Expand Down
7 changes: 5 additions & 2 deletions openslides_backend/action/actions/meeting_user/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
from ...util.default_schema import DefaultSchema
from ...util.register import register_action
from .history_mixin import MeetingUserHistoryMixin
from .mixin import meeting_user_standard_fields
from .mixin import CheckLockOutPermissionMixin, meeting_user_standard_fields


@register_action("meeting_user.create", action_type=ActionType.BACKEND_INTERNAL)
class MeetingUserCreate(MeetingUserHistoryMixin, CreateAction):
class MeetingUserCreate(
MeetingUserHistoryMixin, CreateAction, CheckLockOutPermissionMixin
):
"""
Action to create a meeting user.
"""
Expand All @@ -38,6 +40,7 @@ def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]:
raise ActionException(
f"MeetingUser instance with user {instance['user_id']} and meeting {instance['meeting_id']} already exists"
)
self.check_locking_status(instance["meeting_id"], instance, instance["user_id"])
return super().update_instance(instance)

def get_history_information(self) -> HistoryInformation | None:
Expand Down
251 changes: 251 additions & 0 deletions openslides_backend/action/actions/meeting_user/mixin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
from typing import Any, cast

from openslides_backend.services.datastore.commands import GetManyRequest

from ....action.action import Action
from ....action.mixins.meeting_user_helper import get_meeting_user
from ....action.util.typing import ActionData, ActionResults
from ....permissions.permissions import Permissions
from ....shared.exceptions import ActionException
from ....shared.filters import And, Filter, FilterOperator, Or
from ....shared.interfaces.write_request import WriteRequest
from ....shared.patterns import fqid_from_collection_and_id
from .history_mixin import MeetingUserHistoryMixin

Expand All @@ -9,9 +17,252 @@
"number",
"vote_weight",
"structure_level_ids",
"locked_out",
]


LockingStatusCheckResult = tuple[str, list[int] | None] # message to broken group ids


class CheckLockOutPermissionMixin(Action):
def perform(
self, action_data: ActionData, user_id: int, internal: bool = False
) -> tuple[WriteRequest | None, ActionResults | None]:
self.meeting_id_to_can_manage_group_ids: dict[int, set[int]] = {}
return super().perform(action_data, user_id, internal)

def check_locking_status(
self,
meeting_id: int | None,
instance: dict[str, Any],
user_id: int | None = None,
user: dict[str, Any] | None = None,
raise_exception: bool = True,
) -> list[LockingStatusCheckResult]:
if not any(
field in instance
for field in [
"locked_out",
"group_ids",
"organization_management_level",
"committee_management_ids",
]
):
return []
result: list[LockingStatusCheckResult] = []
if meeting_id and (user_id or user):
db_instance = (
get_meeting_user(
self.datastore,
meeting_id,
user_id or cast(dict[str, Any], user)["id"],
["locked_out", "group_ids", "meeting_id", "user_id"],
)
or {}
)
else:
db_instance = {}
final: dict[str, Any] = db_instance.copy()
final.update(instance)
if not user_id:
user_id = (user or {}).get("id") or final.get("user_id")
if user_id == self.user_id and final.get("locked_out"):
self._add_message(
"You may not lock yourself out of a meeting", result, raise_exception
)
if user_id:
self._check_setting_oml_cml_for_locking(
cast(int, user_id),
final.get("meeting_id"),
instance,
result,
raise_exception,
)
if not user:
try:
user = self.datastore.get(
fqid_from_collection_and_id("user", cast(int, user_id)),
["organization_management_level", "committee_management_ids"],
)
except Exception as err:
if (
len(err.args)
and isinstance(err.args[0], str)
and "does not exist" in err.args[0]
):
return result
else:
raise err
if not final.get("locked_out"):
return result
if user:
user_copy = user.copy()
user_copy.update(final)
final = user_copy
self._check_setting_locked_for_oml_cml(final, result, raise_exception)
self._check_setting_locked_for_groups(final, result, raise_exception)
return result

def _add_message(
self,
message: str,
result: list[LockingStatusCheckResult],
raise_exception: bool,
group_ids: list[int] | None = None,
) -> None:
if raise_exception:
raise ActionException(message)
result.append((message, group_ids))

def _check_setting_locked_for_groups(
self,
final: dict[str, Any],
result: list[LockingStatusCheckResult],
raise_exception: bool,
) -> None:
if final["meeting_id"] not in self.meeting_id_to_can_manage_group_ids:
groups = self.datastore.filter(
"group",
FilterOperator("meeting_id", "=", final["meeting_id"]),
["permissions", "admin_group_for_meeting_id"],
)
self.meeting_id_to_can_manage_group_ids[final["meeting_id"]] = {
id_
for id_, group in groups.items()
if group.get("admin_group_for_meeting_id")
or Permissions.User.CAN_MANAGE in (group.get("permissions") or [])
}
forbidden_group_ids = self.meeting_id_to_can_manage_group_ids[
final["meeting_id"]
]
if forbidden_groups := forbidden_group_ids.intersection(
final.get("group_ids", [])
):
self._add_message(
f"Group(s) {', '.join([str(id_) for id_ in forbidden_groups])} have user.can_manage permissions and may therefore not be used by users who are locked out",
result,
raise_exception,
list(forbidden_groups),
)

def _check_setting_locked_for_oml_cml(
self,
final: dict[str, Any],
result: list[LockingStatusCheckResult],
raise_exception: bool,
) -> None:
"""
Check function for when "locked_out" is newly set to true in this action.
Looks if the user has an oml or cml, that would inhibit this.
"""
if oml := final.get("organization_management_level"):
self._add_message(
f"Cannot lock user from meeting {final['meeting_id']} as long as he has the OrganizationManagementLevel {oml}",
result,
raise_exception,
)
meeting = self.datastore.get(
fqid_from_collection_and_id("meeting", final["meeting_id"]),
["committee_id"],
)
if meeting["committee_id"] in (final.get("committee_management_ids") or []):
self._add_message(
f"Cannot lock user out of meeting {final['meeting_id']} as he is manager of the meetings committee",
result,
raise_exception,
)

def _check_setting_oml_cml_for_locking(
self,
user_id: int,
meeting_id: int | None,
instance: dict[str, Any],
result: list[LockingStatusCheckResult],
raise_exception: bool,
) -> None:
"""
Check function for when an oml or cml is newly set in this action.
Looks if the user is locked out in any meeting, as this would inhibit any oml or cml permissions.
"""
if not (
instance.get("organization_management_level")
or instance.get("committee_management_ids")
):
return
filters = [
FilterOperator("user_id", "=", user_id),
FilterOperator("locked_out", "=", True),
]
if (newly_locked := instance.get("locked_out")) is False and meeting_id:
filters.append(FilterOperator("meeting_id", "!=", meeting_id))
filter_: Filter = And(filters)
if newly_locked and meeting_id:
filter_ = Or(FilterOperator("meeting_id", "=", meeting_id), filter_)
locked_from_meeting_users = self.datastore.filter(
"meeting_user", filter_, ["meeting_id"]
)
locked_from_meeting_ids = {
meeting_user["meeting_id"]
for meeting_user in locked_from_meeting_users.values()
}
if len(locked_from_meeting_ids):
self._check_set_oml_for_locking(
instance, user_id, locked_from_meeting_ids, result, raise_exception
)
self._check_set_cml_for_locking(
instance, user_id, locked_from_meeting_ids, result, raise_exception
)

def _check_set_oml_for_locking(
self,
instance: dict[str, Any],
user_id: int,
locked_from_meeting_ids: set[int],
result: list[LockingStatusCheckResult],
raise_exception: bool,
) -> None:
if (oml := instance.get("organization_management_level")) and len(
locked_from_meeting_ids
):
self._add_message(
f"Cannot give OrganizationManagementLevel {oml} to user {user_id} as he is locked out of meeting(s) {', '.join([str(id_) for id_ in locked_from_meeting_ids])}",
result,
raise_exception,
)

def _check_set_cml_for_locking(
self,
instance: dict[str, Any],
user_id: int,
locked_from_meeting_ids: set[int],
result: list[LockingStatusCheckResult],
raise_exception: bool,
) -> None:
if committee_ids := instance.get("committee_management_ids"):
committees = self.datastore.get_many(
[GetManyRequest("committee", committee_ids, ["meeting_ids", "id"])]
)["committee"]
meeting_id_to_committee_id = {
meeting_id: committee["id"]
for committee in committees.values()
for meeting_id in committee.get("meeting_ids", [])
}
if len(
forbidden_committee_meeting_ids := locked_from_meeting_ids.intersection(
meeting_id_to_committee_id.keys()
)
):
forbidden_committee_ids = {
str(meeting_id_to_committee_id[meeting_id])
for meeting_id in forbidden_committee_meeting_ids
}
self._add_message(
f"Cannot set user {user_id} as manager for committee(s) {', '.join(forbidden_committee_ids)} due to being locked out of meeting(s) {', '.join([str(id_) for id_ in forbidden_committee_meeting_ids])}",
result,
raise_exception,
)


class MeetingUserMixin(MeetingUserHistoryMixin):
def update_instance(self, instance: dict[str, Any]) -> dict[str, Any]:
instance = super().update_instance(instance)
Expand Down
Loading

0 comments on commit b9d51b6

Please sign in to comment.