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

new saml meeting mapping #2722

Merged
merged 17 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion docs/actions/user.save_saml_account.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
pronoun: string,
is_active: boolean,
is_physical_person: boolean,
member_number: string,
// Additional meeting related data can be given. See below explanation on meeting mappers.
}
```

Expand All @@ -31,7 +33,61 @@ Extras to do on creation:

As you can see there is no password for local login and the user can't change it.

- Add user to the meeting by adding him to the group given in the organization-wide field-mapping as `"meeting": { "external_id": "xyz", "external_group_id": "delegates"}` if a `meeting`-entry is given. If it fails for any reason, a log entry is written, but no exception thrown. Add the user always to the group, if it fails try to add him to the default group.
### Meeting Mappers
- The saml attribute mapping can have a list of 'meeting_mappers' that can be used to assign users meeting related data. (See example below.)
- A mapper can be given a 'name' for debugging purposes.
- The 'external_id' maps to the meeting and is required (logged as warning if meeting does not exist). Multiple mappers can map to the same meeting.
- If 'allow_update' is set to false, the mapper is only used if the user does not already exist. If it is not given it defaults to true.
- Mappers are only used if every condition in the list of 'conditions' resolves to true. For this the 'attribute' in the payload data needs to match the string or regex given in 'condition'. If no condition is given this defaults to true.
- The actual mappings are objects or lists of objects of attribute-default pairs (exception: number, which only has the option of an attribute).
- The attribute refers to the payloads data.
- A default value can be given in case the payloads attribute does not exist or contains no data. (Logged as debug)
- Groups and structure levels are given as a list of attribute-default pairs.
- On conflict of multiple mappers mappings on a same meetings field the last given mappers data for that field is used. Exception to this are groups and structure levels. Their data is combined.
- Values for groups and structure levels can additionally be given in comma separated lists composed as a single string.
- Values for groups are interpreted as their external ID and structure levels as their name within that meeting.
- If no group exists for a meeting and no default is given, the meetings default group is used. (Logged as warning)
- If a structure level does not exist, it is created.
Comment on lines +49 to +50
Copy link
Member

Choose a reason for hiding this comment

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

If you're matching groups via external_id, you should explain that here

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

- Vote weights need to be given as 6 digit decimal strings.

```
"meeting_mappers": [{
"name": "Mapper-Name",
"external_id": "M2025",
"allow_update": "false",
"conditions": [{
"attribute": "membernumber",
"condition": "1426\d{4,6}$"
}, {
"attribute": "function",
"condition": "board"
}],
"mappings": {
"groups": [{
"attribute": "membership",
"default": "admin, standard"
}],
"structure_levels": [{
"attribute": "ovname",
"default": "struct1, struct2"
}],
"number": {"attribute": "p_number"},
"comment": {
"attribute": "idp_comment",
"default": "Group set via SSO"
},
"vote_weight": {
"attribute": "vote",
"default":"1.000000"
},
"present": {
"attribute": "present_key",
"default":"True"
}
}
}]
```
If you are using Keycloak as your SAML-server, make sure to fill the attributes of all users. Then you also need to configure for each attribute in 'Clients' a mapping for your Openslides services 'Client Scopes'. Choose 'User Attribute' and assign the 'User Attribute' as in the step before and the 'SAML Attribut Name' as defined in Openslides 'meeting_mappers'.

## Return Value

Expand Down
3 changes: 2 additions & 1 deletion global/data/example-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@
"gender": "gender",
"pronoun": "pronoun",
"is_active": "is_active",
"is_physical_person": "is_person"
"is_physical_person": "is_person",
"member_number": "member_number"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion global/data/initial-data.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
"gender": "gender",
"pronoun": "pronoun",
"is_active": "is_active",
"is_physical_person": "is_person"
"is_physical_person": "is_person",
"member_number": "member_number"
}
}
},
Expand Down
58 changes: 41 additions & 17 deletions openslides_backend/action/actions/meeting_user/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,28 +54,52 @@ def get_history_information(self) -> HistoryInformation | None:
information = {}
for instance in self.instances:
instance_information = []
if "group_ids" in instance:
if len(instance["group_ids"]) == 1:
instance_information.extend(
[
"Participant added to group {} in meeting {}",
fqid_from_collection_and_id(
"group", instance["group_ids"][0]
),
]
fqids_per_collection = {
collection_name: [
fqid_from_collection_and_id(
collection_name,
_id,
)
else:
instance_information.append(
"Participant added to multiple groups in meeting {}",
)
else:
instance_information.append(
"Participant added to meeting {}",
)
for _id in ids
]
for collection_name in ["group", "structure_level"]
if (ids := instance.get(f"{collection_name}_ids"))
}
instance_information.append(
self.compose_history_string(list(fqids_per_collection.items()))
)
for collection_name, fqids in fqids_per_collection.items():
instance_information.extend(fqids)
instance_information.append(
fqid_from_collection_and_id("meeting", instance["meeting_id"]),
)
information[fqid_from_collection_and_id("user", instance["user_id"])] = (
instance_information
)
return information

def compose_history_string(
self, fqids_per_collection: list[tuple[str, list[str]]]
) -> str:
"""
Composes a string of the shape:
Participant added to groups {}, {} and structure levels {} in meeting {}.
"""
middle_sentence_parts = [
" ".join(
[ # prefix and to collection name if it's not the first in list
("and " if collection_name != fqids_per_collection[0][0] else "")
+ collection_name.replace("_", " ") # replace for human readablity
+ ("s" if len(fqids) != 1 else ""), # plural s
", ".join(["{}" for _ in range(len(fqids))]),
]
)
for collection_name, fqids in fqids_per_collection
]
return " ".join(
[
"Participant added to",
*middle_sentence_parts,
("in " if fqids_per_collection else "") + "meeting {}.",
]
)
23 changes: 17 additions & 6 deletions openslides_backend/action/actions/organization/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,24 @@ class OrganizationUpdate(
field: {**optional_str_schema, "max_length": 256}
for field in allowed_user_fields
}
saml_props["meeting"] = {
"type": ["object", "null"],
"properties": {
field: {**optional_str_schema, "max_length": 256}
for field in ("external_id", "external_group_id")
saml_props["meeting_mappers"] = {
"type": ["array", "null"],
"items": {
"type": "object",
"properties": {
**{
field: {**optional_str_schema, "max_length": 256}
for field in ("external_id", "name", "allow_update")
},
"conditions": {
"type": ["array", "null"],
"max_length": 256,
}, # , "items": {"object"}
"mappings": {"type": ["object", "array"], "max_length": 256},
},
"required": ["external_id"],
"additionalProperties": False,
},
"additionalProperties": False,
}
schema = DefaultSchema(Organization()).get_update_schema(
optional_properties=group_A_fields + group_B_fields,
Expand Down
2 changes: 1 addition & 1 deletion openslides_backend/action/actions/speaker/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ def validate_fields(self, instance: dict[str, Any]) -> dict[str, Any]:
user = self.datastore.get(user_fqid, ["is_present_in_meeting_ids"])
if meeting_id not in user.get("is_present_in_meeting_ids", ()):
raise ActionException(
"Only present users can be on the lists of speakers."
"Only present users can be on the list of speakers."
)

if not meeting.get("list_of_speakers_allow_multiple_speakers"):
Expand Down
Loading