diff --git a/server/openslides/saml/README.md b/server/openslides/saml/README.md index f8b9e3aa8..d2ab9e5c7 100644 --- a/server/openslides/saml/README.md +++ b/server/openslides/saml/README.md @@ -35,17 +35,17 @@ folder next to the ``saml_settings.json``. The following settings are given in the `saml_settings.json`. All entries are required, except for the request settings. -### General settings +### `generalSettings` Here you can provide a custom text for the SAML login button. The `changePasswordUrl` redirects the user to the given URL when click on `Change password` in the OpenSlides user menu. -### Attributes -The identity provider sends attributes to the server if a user sucessfully logged in. To +### `attributeMapping` +The identity provider sends attributes to the server if a user successfully logged in. To map these attributes to attributes of OpenSlides users, the section `attributeMapping` exists. The structure is like this:: - "attributeMapping: { + "attributeMapping": { "attributeFromIDP": ["attributeOfOpenSlidesUser", ], "anotherAttributeFromIDP": ["anotherAttributeOfOpenSlidesUser", ] } @@ -56,7 +56,7 @@ All available OpenSlides user attributes are: - ``first_name``: The user's first name. - ``last_name``: The user's last name. - ``title``: The title of the user, e.g. "Dr.". -- ``email``: The user's email addreess. +- ``email``: The user's email address. - ``structure_level``: The structure level. - ``number``: The participant number (text, not an actual number). Note: This field is not unique. - ``about_me``: A free text field. @@ -71,7 +71,7 @@ created with all values given. Try to choose unique attributes (e.g. the usernam attributes you are sure about to be unique (e.g. maybe the number) or use a combination of attributes. -### Requests +### `requestsSettings` One can overwrite the data extracted from the request headers of saml-requests. E.g. if the public port is 80 and the server is reverse-proxied and listen to port 8000, one should set the `server_port` to 80, so OpenSlides does not take the port of the request header. If not specified all these values are taken from the requests meta information: @@ -80,8 +80,29 @@ One can overwrite the data extracted from the request headers of saml-requests. - ``script_name``: The aquivalent to ``PATH_INFO`` in the meta values. - ``server_port``: The port listen by the server. -### Default group ids +### `groups` -If the optional key `defaultGroupIds` is given, these groups are assigned to -each new created user on each saml login. It must be a list of ids. To disable -this feature, either just do not inlcude this key, or set it to `null`. +The optional key `groups` can contain rules to assign groups to new created users on saml logins. + +First, there is an optional list of matchers (may not be given or empty). Each amtcher matches an attribute against an regex. If an attribute value matches the regex, the groups given in `groups` (list of groups) will be added to the user. This is done for all matchers indipendently, so if multiple matchers matches, all groups are used. + +If no matcher matches (also if there is no matcher), the groups in `default_groups` will be used. This key is also optional. Leaving it out or using an empty list will not assign default groups. + +An example with two matchers and default groups: +``` +"groups": { + "matchers": [ + { + "attribute": "attr1", + "regex": "^.*test.*$", + "group_ids": [1] + }, + { + "attribute": "attr2", + "regex": "^012.*$", + "group_ids": [2, 3] + } + ], + "default_group_ids": [5] +} +``` diff --git a/server/openslides/saml/saml_settings.json.tpl b/server/openslides/saml/saml_settings.json.tpl index 08e85a6d5..439ddbed0 100644 --- a/server/openslides/saml/saml_settings.json.tpl +++ b/server/openslides/saml/saml_settings.json.tpl @@ -66,5 +66,7 @@ "UserID": ["username", true], "FirstName": ["first_name", false], "LastName": ["last_name", false] - } + }, + "requestSettings": {}, + "groups": {} } diff --git a/server/openslides/saml/settings.py b/server/openslides/saml/settings.py index 4090139cc..017a76fc6 100644 --- a/server/openslides/saml/settings.py +++ b/server/openslides/saml/settings.py @@ -1,6 +1,7 @@ import json import logging import os +import re from typing import Dict, Tuple from django.conf import settings @@ -94,7 +95,14 @@ class SamlSettings: - request_settings: { : , } - - default_group_ids: [, ...] | null | undefined + - groups: { + matchers: [{ + regex: , + attribute: , + group_ids: [, ...] + }], + default_group_ids: [, ...] | null | undefined + } """ def __init__(self): @@ -122,7 +130,7 @@ class SamlSettings: self.load_general_settings(content) self.load_attribute_mapping(content) self.load_request_settings(content) - self.load_default_group_ids(content) + self.load_groups(content) # Load saml settings self.saml_settings = OneLogin_Saml2_Settings( @@ -213,19 +221,39 @@ class SamlSettings: ] not in ("on", "off"): raise SamlException('The https value must be "on" or "off"') - def load_default_group_ids(self, content): - self.default_group_ids = content.pop("defaultGroupIds", None) - if self.default_group_ids is None: - return - if not isinstance(self.default_group_ids, list): - raise SamlException( - "default_group_ids must be null (or not present) or a list of integers" - ) - for id in self.default_group_ids: - if not isinstance(id, int): - raise SamlException( - "default_group_ids must be null (or not present) or a list of integers" - ) + def load_groups(self, content): + self.groups = content.pop("groups", {}) + matchers = self.groups.get("matchers", []) + for i, m in enumerate(matchers): + if not isinstance(m.get("attribute"), str): + raise SamlException(f"Attribute of matcher {i+1} must be a string") + regex = m["regex"] + if not regex: + raise SamlException(f"Matcher {i+1} does not have a regex") + try: + regex = re.compile(regex) + except re.error as e: + raise SamlException(f"The regex for matcher {i+1} is invalid: {str(e)}") + m["regex"] = regex + m["group_ids"] = m.get("group_ids", []) + if not m["group_ids"]: + raise SamlException(f"The group_ids for matcher {i+1} is empty") + self.assert_list_of_ints(m["group_ids"], f"group_ids of matcher {i+1}") + self.groups["matchers"] = matchers + + default_group_ids = self.groups.pop("default_group_ids", []) + self.assert_list_of_ints(default_group_ids, "default_group_ids") + self.groups["default_group_ids"] = default_group_ids + + logger.info(f"Loaded {len(matchers)} matchers") + logger.info(f"Configured {default_group_ids} as default groups") + + def assert_list_of_ints(self, value, name): + if not isinstance(value, list): + raise SamlException(f"{name} must be a list of integers") + for x in value: + if not isinstance(x, int): + raise SamlException(f"Each entry of {name} must be an integer") saml_settings = None diff --git a/server/openslides/saml/views.py b/server/openslides/saml/views.py index fa1ce69c7..bc343f743 100644 --- a/server/openslides/saml/views.py +++ b/server/openslides/saml/views.py @@ -143,9 +143,7 @@ class SamlView(View): logger.info( f"Created new saml user with id {user.id} and username {user.username}" ) - group_ids = get_saml_settings().default_group_ids - if group_ids: - user.groups.add(*group_ids) + self.add_groups_to_user(user, attributes) inform_changed_data(user) # put the new user into the cache else: logger.info( @@ -154,6 +152,43 @@ class SamlView(View): self.update_user(user, queryargs["defaults"]) auth_login(request, user) + def add_groups_to_user(self, user, attributes): + groups = get_saml_settings().groups + group_ids = set() + for i, m in enumerate(groups["matchers"]): + raw_attr_value = attributes.get(m["attribute"], "") + # Convert the raw_attr_value to a list of string. + # It can be a string or list of strings. Other types will be ignored. + attr_values = None + if isinstance(raw_attr_value, str): + attr_values = [raw_attr_value] + elif isinstance(raw_attr_value, list) and all( + isinstance(x, str) for x in raw_attr_value + ): + attr_values = raw_attr_value + if attr_values is None: + logger.warning( + f"Want to match matcher {i+1} but the attribute value is not a string or a list of strings but {type(raw_attr_value)}" + ) + continue + + if any(m["regex"].search(x) for x in attr_values): + group_ids.update(m["group_ids"]) + logger.info(f"Matcher {i+1} matched. Adding groups: {m['group_ids']}") + else: + logger.info(f"Matcher {i+1} did not match") + + # since a matcher must have groups, not having groups here means no matcher matched + if not group_ids: + logger.info( + f"No matcher matched. Adding default groups: {groups['default_group_ids']}" + ) + group_ids = groups["default_group_ids"] + + logger.info(f"Adding these groups to the user: {list(group_ids)}") + if group_ids: + user.groups.add(*group_ids) + def get_queryargs(self, attributes): """ Build the arguments for getting or creating a user