From b6f83742a75890c859f6088b028fc40c6f0c55df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20K=C3=BCnsem=C3=B6ller?= <jkuensem@physik.uni-bielefeld.de> Date: Wed, 21 Apr 2021 10:10:00 +0200 Subject: [PATCH] TMSS-719: claim project roles from OIDC/Keycloak and adapt user model to hold them --- SAS/TMSS/backend/src/tmss/CMakeLists.txt | 1 + .../src/tmss/authentication_backends.py | 41 ++++++++++++ SAS/TMSS/backend/src/tmss/settings.py | 6 +- SAS/TMSS/backend/src/tmss/tmssapp/admin.py | 6 +- .../tmss/tmssapp/migrations/0001_initial.py | 67 +++++++++++++++---- .../src/tmss/tmssapp/models/permissions.py | 12 ++++ .../src/tmss/tmssapp/models/scheduling.py | 2 +- SAS/TMSS/backend/src/tmss/tmssapp/populate.py | 8 ++- .../tmss/tmssapp/serializers/specification.py | 2 +- .../src/tmss/tmssapp/viewsets/permissions.py | 12 +--- .../tmss/tmssapp/viewsets/specification.py | 3 +- SAS/TMSS/backend/test/t_permissions.py | 2 - .../test/t_permissions_system_roles.py | 4 +- .../test/t_tmssapp_specification_REST_API.py | 4 +- SAS/TMSS/backend/test/test_utils.py | 3 +- 15 files changed, 134 insertions(+), 39 deletions(-) create mode 100644 SAS/TMSS/backend/src/tmss/authentication_backends.py diff --git a/SAS/TMSS/backend/src/tmss/CMakeLists.txt b/SAS/TMSS/backend/src/tmss/CMakeLists.txt index 3e7754777f2..eff8df62cd8 100644 --- a/SAS/TMSS/backend/src/tmss/CMakeLists.txt +++ b/SAS/TMSS/backend/src/tmss/CMakeLists.txt @@ -7,6 +7,7 @@ set(_py_files urls.py wsgi.py exceptions.py + authentication_backends.py ) python_install(${_py_files} diff --git a/SAS/TMSS/backend/src/tmss/authentication_backends.py b/SAS/TMSS/backend/src/tmss/authentication_backends.py new file mode 100644 index 00000000000..465146217c9 --- /dev/null +++ b/SAS/TMSS/backend/src/tmss/authentication_backends.py @@ -0,0 +1,41 @@ +from mozilla_django_oidc.auth import OIDCAuthenticationBackend +import logging +from lofar.sas.tmss.tmss.tmssapp.models import ProjectRole + +logger = logging.getLogger(__name__) + +class TMSSOIDCAuthenticationBackend(OIDCAuthenticationBackend): + """ + A custom OIDCAuthenticationBackend, that allows us to perform extra actions when a user gets authenticated, + most importantly we can assign the user's system and project roles according to the claims that we get from the + identity provider. + """ + + def _set_user_project_roles_from_claims(self, user, claims): + project_roles = [] + prefix = 'urn:mace:astron.nl:science:group:lofar:project:' + for entitlement in claims.get('eduperson_entitlement', []): + try: + if entitlement.startswith(prefix): + project, role = entitlement.replace(prefix, '').split(':') + role = role.replace('role=', '') + if ProjectRole.objects.filter(value=role).count() > 0: + project_roles.append({'project': project, 'role': role}) + else: + logger.error('could not parse entitlement=%s because no project role exists that matches the entitlement role=%s' % (entitlement, role)) + except Exception as e: + logger.error('could not parse entitlement=%s because of exception=%s' % (entitlement, e)) + user.project_roles = project_roles + logger.info("### assigned project_roles=%s to user=%s" % (project_roles, user)) + user.save() + + def create_user(self, claims): + user = super(TMSSOIDCAuthenticationBackend, self).create_user(claims) + logger.info('### create user=%s claims=%s' % (user, claims)) + self._set_user_project_roles_from_claims(user, claims) + return user + + def update_user(self, user, claims): + logger.info('### update user=%s claims=%s' % (user, claims)) + self._set_user_project_roles_from_claims(user, claims) + return user \ No newline at end of file diff --git a/SAS/TMSS/backend/src/tmss/settings.py b/SAS/TMSS/backend/src/tmss/settings.py index d7791f7e04d..5c1c7a6250b 100644 --- a/SAS/TMSS/backend/src/tmss/settings.py +++ b/SAS/TMSS/backend/src/tmss/settings.py @@ -221,6 +221,7 @@ REST_FRAMEWORK = { # AUTHENTICATION: simple LDAP, or OpenID, or both AUTHENTICATION_BACKENDS = ('django.contrib.auth.backends.ModelBackend',) +AUTH_USER_MODEL = 'tmssapp.TMSSUser' if "TMSS_LDAPCREDENTIALS" in os.environ.keys(): # plain LDAP @@ -265,7 +266,7 @@ if "OIDC_RP_CLIENT_ID" in os.environ.keys(): # Defaults are for using the Astron dev OIDC (Keycloak) deployment for SDC # !! Set OIDC_RP_CLIENT_ID and OIDC_RP_CLIENT_SECRET in environment (todo: use dbcredentials to configure as we have for LDAP?) - OIDC_RP_SCOPES = "openid email profile" # todo: groups are not a standard scope, how to handle those? + OIDC_RP_SCOPES = "openid email profile eduperson_entitlement" OIDC_RP_CLIENT_ID = os.environ.get('OIDC_RP_CLIENT_ID', 'secret') # Secret, do not put real credentials on Git OIDC_RP_CLIENT_SECRET = os.environ.get('OIDC_RP_CLIENT_SECRET', 'secret') # Secret, do not put real credentials on Git OIDC_RP_SIGN_ALGO = os.environ.get('OIDC_RP_SIGN_ALGO', 'RS256') @@ -276,7 +277,7 @@ if "OIDC_RP_CLIENT_ID" in os.environ.keys(): OIDC_OP_TOKEN_ENDPOINT = os.environ.get('OIDC_OP_TOKEN_ENDPOINT', "https://sdc-dev.astron.nl/auth/realms/master/protocol/openid-connect/token") OIDC_OP_USER_ENDPOINT = os.environ.get('OIDC_OP_USER_ENDPOINT', "https://sdc-dev.astron.nl/auth/realms/master/protocol/openid-connect/userinfo") - AUTHENTICATION_BACKENDS += ('mozilla_django_oidc.auth.OIDCAuthenticationBackend',) + AUTHENTICATION_BACKENDS += ('lofar.sas.tmss.tmss.authentication_backends.TMSSOIDCAuthenticationBackend',) MIDDLEWARE.append('mozilla_django_oidc.middleware.SessionRefresh') if len(AUTHENTICATION_BACKENDS) == 1: @@ -288,7 +289,6 @@ LOGIN_REDIRECT_URL_FAILURE = "/api/" LOGOUT_REDIRECT_URL = "https://sdc-dev.astron.nl/auth/realms/master/account/#/" # so the user can log out of OpenID provider too LOGOUT_REDIRECT_URL_FAILURE = "/api/" - # Password validation # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/admin.py b/SAS/TMSS/backend/src/tmss/tmssapp/admin.py index 8c38f3f3dad..a4e0d5b36c7 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/admin.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/admin.py @@ -1,3 +1,7 @@ from django.contrib import admin -# Register your models here. +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from .models import TMSSUser + +admin.site.register(TMSSUser, UserAdmin) \ No newline at end of file diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0001_initial.py b/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0001_initial.py index fc070c79167..827022a8f7e 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0001_initial.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/migrations/0001_initial.py @@ -1,11 +1,14 @@ -# Generated by Django 3.0.9 on 2021-03-29 13:02 +# Generated by Django 3.0.9 on 2021-04-20 17:32 from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators import django.contrib.postgres.fields import django.contrib.postgres.fields.jsonb import django.contrib.postgres.indexes from django.db import migrations, models import django.db.models.deletion +import django.utils.timezone import lofar.sas.tmss.tmss.tmssapp.models.common import lofar.sas.tmss.tmss.tmssapp.models.specification @@ -15,10 +18,35 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('auth', '0011_update_proxy_permissions'), ] operations = [ + migrations.CreateModel( + name='TMSSUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('project_roles', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, help_text='A list of structures that contain a project name and project role')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), migrations.CreateModel( name='Algorithm', fields=[ @@ -687,6 +715,18 @@ class Migration(migrations.Migration): ('unique_identifier', models.BigAutoField(help_text='Unique global identifier.', primary_key=True, serialize=False)), ], ), + migrations.CreateModel( + name='StationTimeline', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('station_name', models.CharField(editable=False, help_text='The LOFAR station name.', max_length=16)), + ('timestamp', models.DateField(editable=False, help_text='The date (YYYYMMDD).', null=True)), + ('sunrise_start', models.DateTimeField(help_text='Start time of the sunrise.', null=True)), + ('sunrise_end', models.DateTimeField(help_text='End time of the sunrise.', null=True)), + ('sunset_start', models.DateTimeField(help_text='Start time of the sunset.', null=True)), + ('sunset_end', models.DateTimeField(help_text='End time of the sunset.', null=True)), + ], + ), migrations.CreateModel( name='StationType', fields=[ @@ -938,17 +978,6 @@ class Migration(migrations.Migration): ('second', models.ForeignKey(help_text='Second Task Blueprint to connect.', on_delete=django.db.models.deletion.CASCADE, related_name='second_scheduling_relation', to='tmssapp.TaskBlueprint')), ], ), - migrations.CreateModel( - name='StationTimeline', - fields=[ - ('station_name', models.CharField(max_length=16, null=False, editable=False, help_text='The LOFAR station name.')), - ('timestamp', models.DateField(editable=False, null=True, help_text='The date (YYYYMMDD).')), - ('sunrise_start', models.DateTimeField(null=True, help_text='Start time of the sunrise.')), - ('sunrise_end', models.DateTimeField(null=True, help_text='End time of the sunrise.')), - ('sunset_start', models.DateTimeField(null=True, help_text='Start time of the sunset.')), - ('sunset_end', models.DateTimeField(null=True, help_text='End time of the sunset.')), - ], - ), migrations.AddConstraint( model_name='taskrelationselectiontemplate', constraint=models.UniqueConstraint(fields=('name', 'version'), name='taskrelationselectiontemplate_unique_name_version'), @@ -1165,7 +1194,7 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='stationtimeline', - constraint=models.UniqueConstraint(fields=('station_name', 'timestamp'), name='unique_station_time_line'), + constraint=models.UniqueConstraint(fields=('station_name', 'timestamp'), name='unique_station_time_line'), ), migrations.AddConstraint( model_name='schedulingunittemplate', @@ -1479,6 +1508,16 @@ class Migration(migrations.Migration): name='station_type', field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tmssapp.StationType'), ), + migrations.AddField( + model_name='tmssuser', + name='groups', + field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'), + ), + migrations.AddField( + model_name='tmssuser', + name='user_permissions', + field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'), + ), migrations.AddConstraint( model_name='tasktemplate', constraint=models.UniqueConstraint(fields=('name', 'version'), name='tasktemplate_unique_name_version'), diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/models/permissions.py b/SAS/TMSS/backend/src/tmss/tmssapp/models/permissions.py index 8cdbf52b01e..a0fecb1c75b 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/models/permissions.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/models/permissions.py @@ -10,6 +10,18 @@ from django.db.models import ManyToManyField from enum import Enum from rest_framework.permissions import DjangoModelPermissions +from django.contrib.auth.models import AbstractUser +from django.contrib.postgres.fields import JSONField + +class TMSSUser(AbstractUser): + """ + A custom user model that allows for additional information on the user like project roles. + """ + # todo: The project roles field feels very free-form at the moment. + # Maybe this can be modeled better somehow, with references to the ProjectRole table? + # Otherwise, we should probably come up with a schema to makes sure things are consistent. + # Also, I'd suggest to simply map project name to a list of roles instead of the structure that is used here. + project_roles = JSONField(null=True, blank=True, help_text='A list of structures that contain a project name and project role') # e.g. [{'project': 'high', 'role': 'PI'}, {'project': 'high', 'role': 'Friend of Project'}] # diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/models/scheduling.py b/SAS/TMSS/backend/src/tmss/tmssapp/models/scheduling.py index 9535b3c3d97..231c5586d25 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/models/scheduling.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/models/scheduling.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta from django.db.models import Model, ForeignKey, OneToOneField, CharField, DateTimeField, BooleanField, IntegerField, BigIntegerField, \ ManyToManyField, CASCADE, SET_NULL, PROTECT, QuerySet, BigAutoField, UniqueConstraint from django.contrib.postgres.fields import ArrayField, JSONField -from django.contrib.auth.models import User +from .permissions import TMSSUser as User from .common import AbstractChoice, BasicCommon, Template, NamedCommon, annotate_validate_add_defaults_to_doc_using_template from enum import Enum from django.db.models.expressions import RawSQL diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/populate.py b/SAS/TMSS/backend/src/tmss/tmssapp/populate.py index 684280c9ad3..999d7111dfb 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/populate.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/populate.py @@ -28,7 +28,9 @@ from lofar.sas.tmss.tmss.tmssapp.models.permissions import * from lofar.sas.tmss.tmss.tmssapp.conversions import timestamps_and_stations_to_sun_rise_and_set, get_all_stations from lofar.common import isTestEnvironment, isDevelopmentEnvironment from concurrent.futures import ThreadPoolExecutor -from django.contrib.auth.models import User, Group, Permission +from django.contrib.auth.models import Group, Permission +from django.contrib.auth import get_user_model +User = get_user_model() from django.contrib.contenttypes.models import ContentType from django.db.utils import IntegrityError @@ -593,8 +595,8 @@ def assign_system_permissions(): tmss_admin_group.permissions.add(perm) # User model permissions - ct = ContentType.objects.get(model='user') - perm = Permission.objects.get(codename='add_user') + ct = ContentType.objects.get(model='tmssuser') + perm = Permission.objects.get(codename='add_tmssuser') to_observer_group.permissions.add(perm) sdco_support_group.permissions.add(perm) tmss_maintainer_group.permissions.add(perm) diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/serializers/specification.py b/SAS/TMSS/backend/src/tmss/tmssapp/serializers/specification.py index fc23e9e9424..0b5255b7a76 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/serializers/specification.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/serializers/specification.py @@ -7,7 +7,7 @@ from .. import models from .scheduling import SubtaskSerializer from .common import FloatDurationField, RelationalHyperlinkedModelSerializer, AbstractTemplateSerializer, DynamicRelationalHyperlinkedModelSerializer from .widgets import JSONEditorField -from django.contrib.auth.models import User +from ..models import TMSSUser as User # This is required for keeping a user reference as ForeignKey in other models # (I think so that the HyperlinkedModelSerializer can generate a URI) diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/permissions.py b/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/permissions.py index 34fa46921a6..2fbb882a21d 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/permissions.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/permissions.py @@ -23,25 +23,16 @@ import urllib.parse def get_project_roles_for_user(user): - # todo: this set of project/role pairs needs to be provided by the OIDC federation and will probably enter TMSS - # as a property on request.user. Create this for the requesting user in the following format: - # project_roles = ({'project': 'high', 'role': 'PI'}, # demo data - # {'project': 'low', 'role': 'Friend of Project'}, # demo data - # {'project': 'test_user_is_pi', 'role': 'PI'}, # for unittests - # {'project': 'test_user_is_contact', 'role': 'Contact Author'}) # for unittests - project_roles = () - # todo: stupid hack to make test pass, because we so far have failed mocking this function out successfully. # Should not hit production! try: if user == models.User.objects.get(username='paulus'): return ({'project': 'test_user_is_shared_support', 'role': 'shared_support_user'}, {'project': 'test_user_is_contact', 'role': 'contact_author'}) - #{'project': 'high', 'role': 'shared_support_user'}) except: pass - return project_roles + return tuple(user.project_roles) def get_project_roles_with_permission(permission_name, method='GET'): @@ -55,6 +46,7 @@ def get_project_roles_with_permission(permission_name, method='GET'): logger.error("This action was configured to enforce project permissions, but no project permission with name '%s' has been defined." % permission_name) return [] + class IsProjectMember(drf_permissions.DjangoObjectPermissions): """ Object-level permission to only allow users of the related project to access it. diff --git a/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/specification.py b/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/specification.py index 49ddf7a0971..a71c0236ec6 100644 --- a/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/specification.py +++ b/SAS/TMSS/backend/src/tmss/tmssapp/viewsets/specification.py @@ -5,7 +5,8 @@ This file contains the viewsets (based on the elsewhere defined data models and from django.shortcuts import get_object_or_404, get_list_or_404, render from django.http import JsonResponse -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model +User = get_user_model() from django_filters import rest_framework as filters import django_property_filter as property_filters from rest_framework.viewsets import ReadOnlyModelViewSet diff --git a/SAS/TMSS/backend/test/t_permissions.py b/SAS/TMSS/backend/test/t_permissions.py index e79c126f907..5b7bfa469b6 100755 --- a/SAS/TMSS/backend/test/t_permissions.py +++ b/SAS/TMSS/backend/test/t_permissions.py @@ -51,8 +51,6 @@ from lofar.sas.tmss.test.tmss_test_data_rest import TMSSRESTTestDataCreator from django.test import TestCase -from django.contrib.auth.models import User, Group, Permission - class ProjectPermissionTestCase(TestCase): # This tests that the project permissions are enforced in light of the project roles that are externally provided # for the user through the user admin. This test does not rely on the project permissions as defined in the system, diff --git a/SAS/TMSS/backend/test/t_permissions_system_roles.py b/SAS/TMSS/backend/test/t_permissions_system_roles.py index 5d05682bec0..33599b96af1 100755 --- a/SAS/TMSS/backend/test/t_permissions_system_roles.py +++ b/SAS/TMSS/backend/test/t_permissions_system_roles.py @@ -66,7 +66,9 @@ test_data_creator = TMSSRESTTestDataCreator(BASE_URL, AUTH) from lofar.sas.tmss.tmss.tmssapp.viewsets.permissions import TMSSPermissions from lofar.sas.tmss.tmss.tmssapp.viewsets.scheduling import SubtaskViewSet -from django.contrib.auth.models import User, Group +from django.contrib.auth.models import Group +from django.contrib.auth import get_user_model +User = get_user_model() class SystemPermissionTestCase(unittest.TestCase): diff --git a/SAS/TMSS/backend/test/t_tmssapp_specification_REST_API.py b/SAS/TMSS/backend/test/t_tmssapp_specification_REST_API.py index f0c8c331dc9..f93eb08abbe 100755 --- a/SAS/TMSS/backend/test/t_tmssapp_specification_REST_API.py +++ b/SAS/TMSS/backend/test/t_tmssapp_specification_REST_API.py @@ -43,7 +43,9 @@ from lofar.sas.tmss.test.tmss_test_environment_unittest_setup import * from lofar.sas.tmss.test.tmss_test_data_django_models import * from lofar.sas.tmss.tmss.tmssapp import models from lofar.sas.tmss.test.test_utils import assertUrlList -from django.contrib.auth.models import User, Group, Permission +from django.contrib.auth.models import Group, Permission +from django.contrib.auth import get_user_model +User = get_user_model() # import and setup test data creator from lofar.sas.tmss.test.tmss_test_data_rest import TMSSRESTTestDataCreator diff --git a/SAS/TMSS/backend/test/test_utils.py b/SAS/TMSS/backend/test/test_utils.py index 88c46e4780d..f05cde5b4a6 100644 --- a/SAS/TMSS/backend/test/test_utils.py +++ b/SAS/TMSS/backend/test/test_utils.py @@ -373,7 +373,8 @@ class TMSSTestEnvironment: # now that the ldap and django server are running, and the django set has been done, # we can announce our test user as superuser, so the test user can do anythin via the API. # (there are also other tests, using other (on the fly created) users with restricted permissions, which is fine but not part of this generic setup. - from django.contrib.auth.models import User + from django.contrib.auth import get_user_model + User = get_user_model() user, _ = User.objects.get_or_create(username=self.ldap_server.dbcreds.user) user.is_superuser = True user.save() -- GitLab