mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-20 21:46:04 -05:00
feat: RBAC + SAML support
This commit is contained in:
0
rbac/__init__.py
Normal file
0
rbac/__init__.py
Normal file
212
rbac/admin.py
Normal file
212
rbac/admin.py
Normal file
@@ -0,0 +1,212 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.db import transaction
|
||||
from django.utils.html import format_html
|
||||
|
||||
from files.models import Category
|
||||
from users.models import User
|
||||
|
||||
from .models import RBACGroup, RBACMembership, RBACRole
|
||||
|
||||
|
||||
class RoleFilter(admin.SimpleListFilter):
|
||||
title = 'Role'
|
||||
parameter_name = 'role'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return RBACRole.choices
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value():
|
||||
return queryset.filter(memberships__role=self.value()).distinct()
|
||||
return queryset
|
||||
|
||||
|
||||
class RBACGroupAdminForm(forms.ModelForm):
|
||||
categories = forms.ModelMultipleChoiceField(
|
||||
queryset=Category.objects.filter(is_rbac_category=True),
|
||||
required=False,
|
||||
widget=admin.widgets.FilteredSelectMultiple('Categories', False),
|
||||
help_text='Select categories this RBAC group has access to',
|
||||
)
|
||||
|
||||
members_field = forms.ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Members', False), help_text='Users with Member role', label=''
|
||||
)
|
||||
|
||||
contributors_field = forms.ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Contributors', False), help_text='Users with Contributor role', label=''
|
||||
)
|
||||
|
||||
managers_field = forms.ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Managers', False), help_text='Users with Manager role', label=''
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RBACGroup
|
||||
fields = ('name',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.pk:
|
||||
self.fields['categories'].initial = self.instance.categories.all()
|
||||
|
||||
self.fields['members_field'].initial = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=RBACRole.MEMBER)
|
||||
self.fields['contributors_field'].initial = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=RBACRole.CONTRIBUTOR)
|
||||
self.fields['managers_field'].initial = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=RBACRole.MANAGER)
|
||||
|
||||
def save(self, commit=True):
|
||||
group = super().save(commit=True)
|
||||
|
||||
if commit:
|
||||
self.save_m2m()
|
||||
|
||||
if 'categories' in self.cleaned_data:
|
||||
self.instance.categories.set(self.cleaned_data['categories'])
|
||||
|
||||
return group
|
||||
|
||||
@transaction.atomic
|
||||
def save_m2m(self):
|
||||
if self.instance.pk:
|
||||
member_users = self.cleaned_data['members_field']
|
||||
contributor_users = self.cleaned_data['contributors_field']
|
||||
manager_users = self.cleaned_data['managers_field']
|
||||
|
||||
self._update_role_memberships(RBACRole.MEMBER, member_users)
|
||||
self._update_role_memberships(RBACRole.CONTRIBUTOR, contributor_users)
|
||||
self._update_role_memberships(RBACRole.MANAGER, manager_users)
|
||||
|
||||
def _update_role_memberships(self, role, new_users):
|
||||
new_user_ids = User.objects.filter(pk__in=new_users).values_list('pk', flat=True)
|
||||
|
||||
existing_users = User.objects.filter(rbac_memberships__rbac_group=self.instance, rbac_memberships__role=role)
|
||||
|
||||
existing_user_ids = existing_users.values_list('pk', flat=True)
|
||||
|
||||
users_to_add = User.objects.filter(pk__in=new_user_ids).exclude(pk__in=existing_user_ids)
|
||||
users_to_remove = existing_users.exclude(pk__in=new_user_ids)
|
||||
|
||||
for user in users_to_add:
|
||||
RBACMembership.objects.get_or_create(user=user, rbac_group=self.instance, role=role)
|
||||
|
||||
RBACMembership.objects.filter(user__in=users_to_remove, rbac_group=self.instance, role=role).delete()
|
||||
|
||||
|
||||
class RBACGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'get_member_count', 'get_contributor_count', 'get_manager_count', 'categories_list')
|
||||
form = RBACGroupAdminForm
|
||||
list_filter = (RoleFilter,)
|
||||
search_fields = ['name', 'uid', 'description', 'identity_provider__name']
|
||||
filter_horizontal = ['categories']
|
||||
change_form_template = 'admin/rbac/rbacgroup/change_form.html'
|
||||
|
||||
def get_list_filter(self, request):
|
||||
list_filter = list(self.list_filter)
|
||||
|
||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
list_filter.insert(-1, "identity_provider")
|
||||
|
||||
return list_filter
|
||||
|
||||
def get_list_display(self, request):
|
||||
list_display = list(self.list_display)
|
||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
list_display.insert(-1, "identity_provider")
|
||||
|
||||
return list_display
|
||||
|
||||
def get_member_count(self, obj):
|
||||
return obj.memberships.filter(role=RBACRole.MEMBER).count()
|
||||
|
||||
get_member_count.short_description = 'Members'
|
||||
|
||||
def get_contributor_count(self, obj):
|
||||
return obj.memberships.filter(role=RBACRole.CONTRIBUTOR).count()
|
||||
|
||||
get_contributor_count.short_description = 'Contributors'
|
||||
|
||||
def get_manager_count(self, obj):
|
||||
return obj.memberships.filter(role=RBACRole.MANAGER).count()
|
||||
|
||||
get_manager_count.short_description = 'Managers'
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
'fields': ('uid', 'name', 'description', 'created_at', 'updated_at'),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = super().get_fieldsets(request, obj)
|
||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
'fields': ('identity_provider', 'uid', 'name', 'description', 'created_at', 'updated_at'),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if obj:
|
||||
fieldsets += (
|
||||
('Members', {'fields': ['members_field'], 'description': 'Select users for members. The same user cannot be contributor or manager'}),
|
||||
('Contributors', {'fields': ['contributors_field'], 'description': 'Select users for contributors. The same user cannot be member or manager'}),
|
||||
('Managers', {'fields': ['managers_field'], 'description': 'Select users for managers. The same user cannot be member or contributor'}),
|
||||
('Access To Categories', {'fields': ['categories'], 'classes': ['collapse', 'open'], 'description': 'Select which categories this RBAC group has access to'}),
|
||||
)
|
||||
return fieldsets
|
||||
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
def member_count(self, obj):
|
||||
count = obj.memberships.count()
|
||||
return format_html('<a href="?rbac_group__id__exact={}">{} members</a>', obj.id, count)
|
||||
|
||||
member_count.short_description = 'Members'
|
||||
|
||||
def categories_list(self, obj):
|
||||
return ", ".join([c.title for c in obj.categories.all()])
|
||||
|
||||
categories_list.short_description = 'Categories'
|
||||
|
||||
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||
field = super().formfield_for_dbfield(db_field, **kwargs)
|
||||
if db_field.name == 'social_app':
|
||||
field.label = 'ID Provider'
|
||||
return field
|
||||
|
||||
|
||||
class RBACMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ['user', 'rbac_group', 'role', 'joined_at', 'updated_at']
|
||||
|
||||
list_filter = ['role', 'rbac_group', 'joined_at', 'updated_at']
|
||||
|
||||
search_fields = ['user__username', 'user__email', 'rbac_group__name', 'rbac_group__uid']
|
||||
|
||||
raw_id_fields = ['user']
|
||||
autocomplete_fields = ['user']
|
||||
|
||||
readonly_fields = ['joined_at', 'updated_at']
|
||||
|
||||
fieldsets = [(None, {'fields': ['user', 'rbac_group', 'role']}), ('Timestamps', {'fields': ['joined_at', 'updated_at'], 'classes': ['collapse']})]
|
||||
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
for field in RBACGroup._meta.fields:
|
||||
if field.name == 'social_app':
|
||||
field.verbose_name = "ID Provider"
|
||||
|
||||
RBACGroup._meta.verbose_name_plural = "Groups"
|
||||
RBACGroup._meta.verbose_name = "Group"
|
||||
RBACMembership._meta.verbose_name_plural = "Role Based Access Control Membership"
|
||||
RBACGroup._meta.app_config.verbose_name = "Role Based Access Control"
|
||||
|
||||
admin.site.register(RBACGroup, RBACGroupAdmin)
|
||||
admin.site.register(RBACMembership, RBACMembershipAdmin)
|
||||
6
rbac/apps.py
Normal file
6
rbac/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RbacConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'rbac'
|
||||
63
rbac/migrations/0001_initial.py
Normal file
63
rbac/migrations/0001_initial.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-18 17:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('files', '0004_alter_subtitle_options_category_identity_provider_and_more'),
|
||||
('socialaccount', '0006_alter_socialaccount_extra_data'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RBACGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uid', models.CharField(help_text='Unique identifier for the RBAC group (unique per identity provider)', max_length=255)),
|
||||
('name', models.CharField(max_length=100, help_text='MediaCMS Group name')),
|
||||
('description', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('categories', models.ManyToManyField(blank=True, help_text='Categories this RBAC group has access to', related_name='rbac_groups', to='files.category')),
|
||||
(
|
||||
'identity_provider',
|
||||
models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rbac_groups', to='socialaccount.socialapp', verbose_name='IDP Config Name'),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'RBAC Group',
|
||||
'verbose_name_plural': 'RBAC Groups',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RBACMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('member', 'Member'), ('contributor', 'Contributor'), ('manager', 'Manager')], default='member', max_length=20)),
|
||||
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('rbac_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='rbac.rbacgroup')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rbac_memberships', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'RBAC Membership',
|
||||
'verbose_name_plural': 'RBAC Memberships',
|
||||
'unique_together': {('user', 'rbac_group', 'role')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rbacgroup',
|
||||
name='members',
|
||||
field=models.ManyToManyField(related_name='rbac_groups', through='rbac.RBACMembership', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='rbacgroup',
|
||||
unique_together={('name', 'identity_provider'), ('uid', 'identity_provider')},
|
||||
),
|
||||
]
|
||||
19
rbac/migrations/0002_alter_rbacgroup_uid.py
Normal file
19
rbac/migrations/0002_alter_rbacgroup_uid.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-25 14:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import rbac.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('rbac', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='rbacgroup',
|
||||
name='uid',
|
||||
field=models.CharField(default=rbac.models.generate_uid, help_text='Unique identifier for the RBAC group (unique per identity provider)', max_length=255),
|
||||
),
|
||||
]
|
||||
0
rbac/migrations/__init__.py
Normal file
0
rbac/migrations/__init__.py
Normal file
96
rbac/models.py
Normal file
96
rbac/models.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.dispatch import receiver
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
|
||||
def generate_uid():
|
||||
return get_random_string(length=10)
|
||||
|
||||
|
||||
class RBACGroup(models.Model):
|
||||
uid = models.CharField(max_length=255, default=generate_uid, help_text='Unique identifier for the RBAC group (unique per identity provider)')
|
||||
name = models.CharField(max_length=100, help_text='MediaCMS Group name')
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# access to members through the membership model
|
||||
members = models.ManyToManyField("users.User", through='RBACMembership', through_fields=('rbac_group', 'user'), related_name='rbac_groups')
|
||||
|
||||
categories = models.ManyToManyField('files.Category', related_name='rbac_groups', blank=True, help_text='Categories this RBAC group has access to')
|
||||
|
||||
identity_provider = models.ForeignKey(SocialApp, on_delete=models.SET_NULL, null=True, blank=True, related_name='rbac_groups', verbose_name='IDP Config Name')
|
||||
|
||||
def __str__(self):
|
||||
name = f"{self.name}"
|
||||
if self.identity_provider:
|
||||
name = f"{name} for {self.identity_provider}"
|
||||
return name
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'RBAC Group'
|
||||
verbose_name_plural = 'RBAC Groups'
|
||||
unique_together = [['uid', 'identity_provider'], ['name', 'identity_provider']]
|
||||
|
||||
|
||||
class RBACRole(models.TextChoices):
|
||||
MEMBER = 'member', 'Member'
|
||||
CONTRIBUTOR = 'contributor', 'Contributor'
|
||||
MANAGER = 'manager', 'Manager'
|
||||
|
||||
|
||||
class RBACMembership(models.Model):
|
||||
user = models.ForeignKey("users.User", on_delete=models.CASCADE, related_name='rbac_memberships')
|
||||
rbac_group = models.ForeignKey(RBACGroup, on_delete=models.CASCADE, related_name='memberships')
|
||||
role = models.CharField(max_length=20, choices=RBACRole.choices, default=RBACRole.MEMBER)
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['user', 'rbac_group', 'role']
|
||||
verbose_name = 'RBAC Membership'
|
||||
verbose_name_plural = 'RBAC Memberships'
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
return True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.username} - {self.rbac_group.name} ({self.role})'
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=RBACGroup.categories.through)
|
||||
def handle_rbac_group_categories_change(sender, instance, action, pk_set, **kwargs):
|
||||
"""
|
||||
Signal handler for when categories are added to or removed from an RBACGroup.
|
||||
"""
|
||||
if not getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
return
|
||||
|
||||
from files.models import Category
|
||||
from identity_providers.models import IdentityProviderCategoryMapping
|
||||
|
||||
if action == 'post_add':
|
||||
if not instance.identity_provider:
|
||||
return
|
||||
# the following apply only if identity_provider is there
|
||||
for category_id in pk_set:
|
||||
category = Category.objects.get(pk=category_id)
|
||||
|
||||
mapping_exists = IdentityProviderCategoryMapping.objects.filter(identity_provider=instance.identity_provider, name=instance.uid, map_to=category).exists()
|
||||
|
||||
if not mapping_exists:
|
||||
IdentityProviderCategoryMapping.objects.create(identity_provider=instance.identity_provider, name=instance.uid, map_to=category)
|
||||
|
||||
elif action == 'post_remove':
|
||||
for category_id in pk_set:
|
||||
category = Category.objects.get(pk=category_id)
|
||||
|
||||
IdentityProviderCategoryMapping.objects.filter(identity_provider=instance.identity_provider, name=instance.uid, map_to=category).delete()
|
||||
0
rbac/tests.py
Normal file
0
rbac/tests.py
Normal file
0
rbac/views.py
Normal file
0
rbac/views.py
Normal file
Reference in New Issue
Block a user