Skip to content

Commit

Permalink
Add an RBAC system as part of DAB app system
Browse files Browse the repository at this point in the history
Key features are
 - support of resource tree permission inheritance
 - support for grouping users into teams
 - efficient generation of querysets
 - endpoints for RBAC models, permission classes

Added feature for tracking roles with relationships

Added feature to manage singleton permissions
  • Loading branch information
AlanCoding committed Feb 6, 2024
1 parent 3eb2b78 commit cdc8dfc
Show file tree
Hide file tree
Showing 48 changed files with 3,904 additions and 23 deletions.
31 changes: 31 additions & 0 deletions ansible_base/lib/dynamic_config/dynamic_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,34 @@
SOCIAL_AUTH_STORAGE = "ansible_base.authentication.social_auth.AuthenticatorStorage"
SOCIAL_AUTH_STRATEGY = "ansible_base.authentication.social_auth.AuthenticatorStrategy"
SOCIAL_AUTH_LOGIN_REDIRECT_URL = "/"


if 'ansible_base.rbac' in INSTALLED_APPS:
# Settings for the RBAC system, override as necessary in app
ANSIBLE_BASE_ROLE_PRECREATE = {
'object_admin': '{cls._meta.model_name}-admin',
'org_admin': 'organization-admin',
'org_children': 'organization-{cls._meta.model_name}-admin',
'special': '{cls._meta.model_name}-{action}',
}

# Permissions a user will get when creating a new item
ANSIBLE_BASE_CREATOR_DEFAULTS = ['change', 'delete', 'view']

# Specific feature enablement bits
ANSIBLE_BASE_TEAM_TEAM_ALLOWED = True
ANSIBLE_BASE_TEAM_ORG_ALLOWED = True
ANSIBLE_BASE_TEAM_ORG_TEAM_ALLOWED = True

# User flags that can grant permission before consulting roles
ANSIBLE_BASE_BYPASS_SUPERUSER_FLAGS = ['is_superuser']
ANSIBLE_BASE_BYPASS_ACTION_FLAGS = {}

# Allow using a custom permission model
ANSIBLE_BASE_PERMISSION_MODEL = 'auth.Permission'

# Allows managing singleton permissions with a user-defined relationship
ANSIBLE_BASE_SINGLETON_USER_RELATIONSHIP = ''
ANSIBLE_BASE_SINGLETON_TEAM_RELATIONSHIP = ''

ANSIBLE_BASE_SERVICE_PREFIX = 'local'
5 changes: 5 additions & 0 deletions ansible_base/rbac/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from ansible_base.rbac.permission_registry import permission_registry

__all__ = [
'permission_registry',
]
21 changes: 21 additions & 0 deletions ansible_base/rbac/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.contrib import admin

from ansible_base.rbac.models import ObjectRole, RoleDefinition, RoleEvaluation, TeamAssignment, UserAssignment


class ReadOnlyAdmin(admin.ModelAdmin):
"""For cached data by the RBAC system, not editable"""

def has_add_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False


admin.site.register(RoleDefinition)
# TODO: assignments will still not be functional in the admin pages without custom logic
admin.site.register(UserAssignment)
admin.site.register(TeamAssignment)
admin.site.register(ObjectRole, ReadOnlyAdmin)
admin.site.register(RoleEvaluation, ReadOnlyAdmin)
77 changes: 77 additions & 0 deletions ansible_base/rbac/api/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from django.http import Http404
from rest_framework.permissions import SAFE_METHODS, BasePermission, DjangoObjectPermissions

from ansible_base.rbac.evaluations import has_super_permission


class IsSystemAdminOrAuditor(BasePermission):
"""
Allows write access only to system admin users.
Allows read access only to system auditor users.
"""

def has_permission(self, request, view):
if not (request.user and request.user.is_authenticated):
return False
if request.method in SAFE_METHODS:
return has_super_permission(request.user, 'view')
return has_super_permission(request.user)


class AuthenticatedReadAdminChange(IsSystemAdminOrAuditor):
"Any authenticated user can view, but only admin users can do CRUD"

def has_permission(self, request, view):
if not (request.user and request.user.is_authenticated):
return False
if request.method in SAFE_METHODS:
return True
return has_super_permission(request.user)


class AnsibleBaseObjectPermissions(DjangoObjectPermissions):

def has_permission(self, request, view):
"Half of this comes from ModelAccessPermission. We assume user.permissions is unused"
if not request.user or (not request.user.is_authenticated and self.authenticated_users_only):
return False

# Workaround to ensure DjangoModelPermissions are not applied
# to the root view when using DefaultRouter.
if getattr(view, '_ignore_model_permissions', False):
return True

# Here is where one would check if the user has general permission to the _endpont_
# but RBAC models expose their views to all users and hide objects by filtering
# if you need a different behavior you likely need a different class like IsAdminUser

# As an exception to this, AWX calls access methods with None in place of data
# which results in POST or PUT being excluded from OPTIONS for permissions reasons
return True

def has_object_permission(self, request, view, obj):
"Original version of this comes from DjangoModelPermissions, overridden to use has_obj_perm"
queryset = self._queryset(view)
model_cls = queryset.model
user = request.user

perms = self.get_required_object_permissions(request.method, model_cls)

if not all(user.has_obj_perm(obj, perm) for perm in perms):
# If the user does not have permissions we need to determine if
# they have read permissions to see 403, or not, and simply see
# a 404 response.

if request.method in SAFE_METHODS:
# Read permissions already checked and failed, no need
# to make another lookup.
raise Http404

read_perms = self.get_required_object_permissions('GET', model_cls)
if not all(user.has_obj_perm(obj, perm) for perm in read_perms):
raise Http404

# Has read permissions.
return False

return True
9 changes: 9 additions & 0 deletions ansible_base/rbac/api/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from rest_framework.routers import SimpleRouter

from ansible_base.rbac.api import views

router = SimpleRouter()

router.register(r'role_definitions', views.RoleDefinitionViewSet, basename='role_definition')
router.register(r'role_user_assignments', views.UserAssignmentViewSet, basename='userassignment')
router.register(r'role_team_assignments', views.TeamAssignmentViewSet, basename='teamassignment')
152 changes: 152 additions & 0 deletions ansible_base/rbac/api/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied

from ansible_base.lib.serializers.common import CommonModelSerializer
from ansible_base.rbac.models import ObjectRole, RoleDefinition, TeamAssignment, UserAssignment
from ansible_base.rbac.permission_registry import permission_registry # careful for circular imports


class ChoiceLikeMixin(serializers.ChoiceField):
"""
This uses a ForeignKey to populate the choices of a choice field.
This also manages some string manipulation, right now, adding the local service name.
"""

default_error_messages = serializers.PrimaryKeyRelatedField.default_error_messages
psuedo_model = None # define in subclass
psuedo_field = None # define in subclass

def get_dynamic_choices(self):
raise NotImplementedError

def get_dynamic_object(self, data):
raise NotImplementedError

def to_representation(self, value):
raise NotImplementedError

def __init__(self, **kwargs):
choices = self.get_dynamic_choices()
kwargs['help_text'] = self.psuedo_model._meta.get_field(self.psuedo_field).help_text
super().__init__(choices=choices, **kwargs)

def to_internal_value(self, data):
try:
return self.get_dynamic_object(data)
except ObjectDoesNotExist:
self.fail('does_not_exist', pk_value=data)
except (TypeError, ValueError):
self.fail('incorrect_type', data_type=type(data).__name__)


class ContentTypeField(ChoiceLikeMixin):
psuedo_model = permission_registry.content_type_model
psuedo_field = 'model'

def get_dynamic_choices(self):
return [
(f'{settings.ANSIBLE_BASE_SERVICE_PREFIX}.{cls._meta.model_name}', cls._meta.verbose_name.title())
for cls in permission_registry.all_registered_models
]

def get_dynamic_object(self, data):
model = data.rsplit('.')[-1]
return permission_registry.content_type_model.objects.get(model=model)

def to_representation(self, value):
return f'{settings.ANSIBLE_BASE_SERVICE_PREFIX}.{value.model}'


class PermissionField(ChoiceLikeMixin):
psuedo_model = permission_registry.permission_model
psuedo_field = 'codename'

def get_dynamic_choices(self):
perms = []
for cls in permission_registry.all_registered_models:
cls_name = cls._meta.model_name
for action in cls._meta.default_permissions:
perms.append(f'{settings.ANSIBLE_BASE_SERVICE_PREFIX}.{action}_{cls_name}')
for perm_name, description in cls._meta.permissions:
perms.append(f'{settings.ANSIBLE_BASE_SERVICE_PREFIX}.{perm_name}')
return perms

def get_dynamic_object(self, data):
codename = data.rsplit('.')[-1]
return permission_registry.permission_model.objects.get(codename=codename)

def to_representation(self, value):
return f'{settings.ANSIBLE_BASE_SERVICE_PREFIX}.{value.codename}'


class ManyRelatedListField(serializers.ListField):
def to_representation(self, data):
"Adds the .all() to treat the value as a queryset"
return [self.child.to_representation(item) if item is not None else None for item in data.all()]


class RoleDefinitionSerializer(CommonModelSerializer):
reverse_url_name = 'role_definition-detail'
# Relational versions - we may switch to these if custom permission and type models are exposed but out of scope here
# permissions = serializers.SlugRelatedField(many=True, slug_field='codename', queryset=permission_registry.permission_model.objects.all())
# content_type = ContentTypeField(slug_field='model', queryset=permission_registry.content_type_model.objects.all(), allow_null=True, default=None)
permissions = ManyRelatedListField(child=PermissionField())
content_type = ContentTypeField(allow_null=True, default=None)

class Meta:
model = RoleDefinition
fields = '__all__'


class RoleDefinitionDetailSeraizler(RoleDefinitionSerializer):
content_type = ContentTypeField(read_only=True)


class ObjectRoleSerializer(serializers.ModelSerializer):
content_type = ContentTypeField(allow_null=True, default=None)

class Meta:
model = ObjectRole
fields = ('id', 'content_type', 'object_id', 'role_definition')


class BaseAssignmentSerializer(CommonModelSerializer):
object_role = ObjectRoleSerializer(read_only=True)
content_type = ContentTypeField(read_only=True)

def create(self, validated_data):
rd = validated_data['role_definition']
model = rd.content_type.model_class()
obj = model.objects.get(id=validated_data['object_id'])

# validate user has permission
user = validated_data[self.actor_field]
requesting_user = self.context['view'].request.user
if not requesting_user.has_obj_perm(obj, 'change'):
raise PermissionDenied

with transaction.atomic():
assignment = rd.give_permission(user, obj)

return assignment


class UserAssignmentSerializer(BaseAssignmentSerializer):
reverse_url_name = 'userassignment-detail'
actor_field = 'user'

class Meta:
model = UserAssignment
fields = '__all__'


class TeamAssignmentSerializer(BaseAssignmentSerializer):
reverse_url_name = 'teamassignment-detail'
actor_field = 'team'

class Meta:
model = TeamAssignment
fields = '__all__'
53 changes: 53 additions & 0 deletions ansible_base/rbac/api/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from django.db import transaction
from rest_framework import permissions
from rest_framework.exceptions import PermissionDenied
from rest_framework.viewsets import ModelViewSet

from ansible_base.rbac.api.permissions import AuthenticatedReadAdminChange
from ansible_base.rbac.api.serializers import RoleDefinitionDetailSeraizler, RoleDefinitionSerializer, TeamAssignmentSerializer, UserAssignmentSerializer
from ansible_base.rbac.evaluations import has_super_permission
from ansible_base.rbac.models import RoleDefinition


class RoleDefinitionViewSet(ModelViewSet):
"""
As per docs, RoleDefinition is interacted with like a normal model.
"""

queryset = RoleDefinition.objects.all()
serializer_class = RoleDefinitionSerializer
permission_classes = [AuthenticatedReadAdminChange]

def get_serializer_class(self):
if self.action == 'update':
return RoleDefinitionDetailSeraizler
return super().get_serializer_class()


class BaseAssignmentViewSet(ModelViewSet):
permission_classes = [permissions.IsAuthenticated]
# PUT and PATCH are not allowed because these are immutable
http_method_names = ['get', 'post', 'head', 'options', 'delete']

def get_queryset(self):
model = self.serializer_class.Meta.model
if has_super_permission(self.request.user, 'view'):
return model.objects.all()
return model.visible_items(self.request.user)

def perform_destroy(self, instance):
if not self.request.user.has_obj_perm(instance, 'delete'):
raise PermissionDenied

rd = instance.object_role.role_definition
obj = instance.object_role.content_object
with transaction.atomic():
rd.remove_permission(self.request.user, obj)


class TeamAssignmentViewSet(BaseAssignmentViewSet):
serializer_class = TeamAssignmentSerializer


class UserAssignmentViewSet(BaseAssignmentViewSet):
serializer_class = UserAssignmentSerializer
13 changes: 13 additions & 0 deletions ansible_base/rbac/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.apps import AppConfig

from ansible_base.rbac.permission_registry import permission_registry


class AnsibleRBACConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'ansible_base.rbac'
label = 'dab_rbac'
verbose_name = 'DAB shared RBAC'

def ready(self):
permission_registry.call_when_apps_ready(self.apps, self)
Loading

0 comments on commit cdc8dfc

Please sign in to comment.