diff --git a/README.md b/README.md index 819813e8..db37987b 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,21 @@ _The following instructions are provided as a best effort to help get started. T - `single_sign_on_service` Ex: - `single_logout_service` Ex: +## Using groups in the SAML assertion to assign Sal profiles +Sal-saml adds a Django signal callback to act on group membership information passed in a SAML assertion during login. If you can configure your IdP to add group information, you can use it to automate the addition and revocation of permissions. + +To take advantage of this, edit the settings.py that comes with sal-saml for these preferences: +- `SAML_GROUPS_ATTRIBUTE`: Default (`memberOf`) The assertion dict's key for the group membership attribute. +- `SAML_READ_ONLY_GROUPS`: Default `[]` (empty list) List of groups who should be given read-only access. +- `SAML_READ_WRITE_GROUPS`: Default `[]` (empty list) List of groups who should be given read-write access. +- `SAML_GLOBAL_ADMIN_GROUPS` Default `[]` (empty list) List of groups who should be given global admin access. This includes access to the admin site. + +For example: +``` +SAML_READ_ONLY_GROUPS = ['cn=regular_shorts_wearers,ou=memberOf,dc=blutwurst,dc=com', 'cn=nontraditional_pants_krew,ou=memberOf,dc=blutwurst,dc=com'] +SAML_GLOBAL_ADMIN_GROUPS` = ['cn=lederhosen_club,ou=memberOf,dc=blutwurst,dc=com'] +``` + ## An example Docker run Please note that this Docker run is **incomplete**, but shows where to pass the `metadata.xml` and `settings.py`. Also note, `latest` in the below run should not be used unless you have a real reason (needing a development version). When performing `docker run`, you should substitute `latest` for the latest tagged release. diff --git a/docker/settings.py b/docker/settings.py index fde54a02..6f55fded 100644 --- a/docker/settings.py +++ b/docker/settings.py @@ -109,6 +109,16 @@ "sn": ("last_name",), } + # Edit these lists to include the names of groups that should get + # the access levels below. See server/signals.py for more details. + # Leave blank to disable the group-based permissions feature. + SAML_READ_ONLY_GROUPS = [] + SAML_READ_WRITE_GROUPS = [] + SAML_GLOBAL_ADMIN_GROUPS = [] + # Edit to match the attribute name used in your SAML assertions for + # group membership information. + SAML_GROUPS_ATTRIBUTE = 'memberOf' + logging_config = get_sal_logging_config() if DEBUG: level = "DEBUG" @@ -147,11 +157,13 @@ "authn_requests_signed": False, "allow_unsolicited": True, "want_assertions_signed": True, + # Allow SAML assertions to contain attributes not specified in the + # attributemaps. "allow_unknown_attributes": True, "name": "Federated Django sample SP", "name_id_format": NAMEID_FORMAT_PERSISTENT, "endpoints": { - # url and binding to the assetion consumer service view + # url and binding to the assertion consumer service view # do not change the binding or service name "assertion_consumer_service": [ ("https://sal.example.com/saml2/acs/", saml2.BINDING_HTTP_POST), diff --git a/server/apps.py b/server/apps.py index e790762a..918febe0 100644 --- a/server/apps.py +++ b/server/apps.py @@ -4,3 +4,6 @@ class ServerAppConfig(AppConfig): default_auto_field = 'django.db.models.AutoField' name = "server" + + def ready(self): + import server.signals \ No newline at end of file diff --git a/server/signals.py b/server/signals.py new file mode 100644 index 00000000..e53d6131 --- /dev/null +++ b/server/signals.py @@ -0,0 +1,66 @@ +from django.dispatch import receiver + +from djangosaml2.signals import pre_user_save + +from server.models import UserProfile, ProfileLevel +from server.utils import get_django_setting + + +READ_ONLY_GROUPS = set(get_django_setting('SAML_READ_ONLY_GROUPS', [])) +READ_WRITE_GROUPS = set(get_django_setting('SAML_READ_WRITE_GROUPS', [])) +GLOBAL_ADMIN_GROUPS = set(get_django_setting('SAML_GLOBAL_ADMIN_GROUPS', [])) +GROUPS_ATTRIBUTE = get_django_setting('SAML_GROUPS_ATTRIBUTE', 'memberOf') + + +@receiver(pre_user_save) +def update_group_membership( + sender, instance, attributes: dict, user_modified: bool, **kwargs) -> bool: + """Update user's group membership based on passed SAML groups + + Sal access level is based on the highest access level granted across + all groups a user is a member of. For example, if you are in a group + with RO access and a group with GA access, the GA level "wins". + + Users who have no group membership in any of the configured + SAML_X_GROUPS settings will be unchanged, allowing changes to these + users via the admin panel to persist. + + Args: + sender: The class of the user that just logged in. + instance: User instance + attributes: SAML attributes dict. + user_modified: Bool whether the user has been modified + kwargs: + signal: The signal instance + + Returns: + Whether or not the user has been modified. This allows the user + instance to be saved once at the conclusion of the auth process + to keep the writes to a minimum. + """ + assertion_groups = set(attributes.get(GROUPS_ATTRIBUTE, [])) + if GLOBAL_ADMIN_GROUPS.intersection(assertion_groups): + instance.userprofile.delete() + user_profile = UserProfile(user=instance, level=ProfileLevel.global_admin) + user_profile.save() + instance.is_superuser = True + instance.is_staff = True + instance.is_active = True + user_modified = True + elif READ_WRITE_GROUPS.intersection(assertion_groups): + instance.userprofile.delete() + user_profile = UserProfile(user=instance, level=ProfileLevel.read_write) + user_profile.save() + instance.is_superuser = False + instance.is_staff = False + instance.is_active = True + user_modified = True + elif READ_ONLY_GROUPS.intersection(assertion_groups): + instance.userprofile.delete() + user_profile = UserProfile(user=instance, level=ProfileLevel.read_only) + user_profile.save() + instance.is_superuser = False + instance.is_staff = False + instance.is_active = True + user_modified = True + return user_modified