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

Copy signals.py and SAML groups changes from dead sal-saml to sal. #468

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ _The following instructions are provided as a best effort to help get started. T
- `single_sign_on_service` Ex: <https://apps.onelogin.com/trust/saml2/http-post/sso/1234567890>
- `single_logout_service` Ex: <https://apps.onelogin.com/trust/saml2/http-redirect/slo/1234567890>

## 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.
Expand Down
14 changes: 13 additions & 1 deletion docker/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions server/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
class ServerAppConfig(AppConfig):
default_auto_field = 'django.db.models.AutoField'
name = "server"

def ready(self):
import server.signals
66 changes: 66 additions & 0 deletions server/signals.py
Original file line number Diff line number Diff line change
@@ -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
Loading