forked from ansible/django-ansible-base
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add an RBAC system as part of DAB app system
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
1 parent
3eb2b78
commit cdc8dfc
Showing
48 changed files
with
3,904 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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__' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.