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

New RBAC Implementation #2515

Merged
merged 13 commits into from
Oct 30, 2024
Empty file added care/security/__init__.py
Empty file.
11 changes: 11 additions & 0 deletions care/security/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _


class FacilityConfig(AppConfig):
vigneshhari marked this conversation as resolved.
Show resolved Hide resolved
name = "care.security"
verbose_name = _("Security Management")

def ready(self):
# import care.security.signals # noqa F401
pass
Empty file.
88 changes: 88 additions & 0 deletions care/security/controllers/controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from care.security.permissions.permissions import PermissionController

Check warning on line 1 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L1

Added line #L1 was not covered by tests


class PermissionDeniedError(Exception):
pass

Check warning on line 5 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L4-L5

Added lines #L4 - L5 were not covered by tests


class AuthorizationHandler:

Check warning on line 8 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L8

Added line #L8 was not covered by tests
"""
This is the base class for Authorization Handlers
Authorization handler must define a list of actions that can be performed and define the methods that
actually perform the authorization action.

All Authz methods would be of the signature ( user, obj , **kwargs )
obj refers to the obj which the user is seeking permission to. obj can also be a string or any datatype as long
as the logic can handle the type.

Queries are actions that return a queryset as the response.
"""

actions = []
queries = []

Check warning on line 22 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L21-L22

Added lines #L21 - L22 were not covered by tests

def check_permission(self, user, obj):

Check warning on line 24 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L24

Added line #L24 was not covered by tests
if not PermissionController.has_permission(user, obj):
raise PermissionDeniedError

Check warning on line 26 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L26

Added line #L26 was not covered by tests

return PermissionController.has_permission(user, obj)

Check warning on line 28 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L28

Added line #L28 was not covered by tests


class AuthorizationController:

Check warning on line 31 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L31

Added line #L31 was not covered by tests
"""
This class abstracts all security related operations in care
This includes Checking if A has access to resource X,
Filtering query-sets for list based operations and so on.
Security Controller implicitly caches all cachable operations and expects it to be invalidated.

SecurityController maintains a list of override Classes, When present,
The override classes are invoked first and then the predefined classes.
The overridden classes can choose to call the next function in the hierarchy if needed.
"""

override_authz_controllers: list[

Check warning on line 43 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L43

Added line #L43 was not covered by tests
AuthorizationHandler
] = [] # The order is important
# Override Security Controllers will be defined from plugs
internal_authz_controllers: list[AuthorizationHandler] = []

Check warning on line 47 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L47

Added line #L47 was not covered by tests

cache = {}

Check warning on line 49 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L49

Added line #L49 was not covered by tests

@classmethod

Check warning on line 51 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L51

Added line #L51 was not covered by tests
def build_cache(cls):
for controller in (
cls.internal_authz_controllers + cls.override_authz_controllers
):
for action in controller.actions:
if "actions" not in cls.cache:
cls.cache["actions"] = {}
cls.cache["actions"][action] = [

Check warning on line 59 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L58-L59

Added lines #L58 - L59 were not covered by tests
*cls.cache["actions"].get(action, []),
controller,
]

@classmethod

Check warning on line 64 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L64

Added line #L64 was not covered by tests
def get_action_controllers(cls, action):
return cls.cache["actions"].get(action, [])

Check warning on line 66 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L66

Added line #L66 was not covered by tests

@classmethod

Check warning on line 68 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L68

Added line #L68 was not covered by tests
def check_action_permission(cls, action, user, obj):
"""
TODO: Add Caching and capability to remove cache at both user and obj level
"""
if not cls.cache:
cls.build_cache()
controllers = cls.get_action_controllers(action)

Check warning on line 75 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L74-L75

Added lines #L74 - L75 were not covered by tests
for controller in controllers:
permission_fn = getattr(controller, action)
result, _continue = permission_fn(user, obj)

Check warning on line 78 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L77-L78

Added lines #L77 - L78 were not covered by tests
if not _continue:
return result

Check warning on line 80 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L80

Added line #L80 was not covered by tests
if not result:
return result
return True

Check warning on line 83 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L82-L83

Added lines #L82 - L83 were not covered by tests

@classmethod

Check warning on line 85 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L85

Added line #L85 was not covered by tests
def register_internal_controller(cls, controller: AuthorizationHandler):
# TODO : Do some deduplication Logic
cls.internal_authz_controllers.append(controller)

Check warning on line 88 in care/security/controllers/controller.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/controller.py#L88

Added line #L88 was not covered by tests
23 changes: 23 additions & 0 deletions care/security/controllers/facility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from care.abdm.utils.api_call import Facility
from care.facility.models import FacilityUser
from care.security.controllers.controller import (

Check warning on line 3 in care/security/controllers/facility.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/facility.py#L1-L3

Added lines #L1 - L3 were not covered by tests
AuthorizationHandler,
PermissionDeniedError,
)


class FacilityAccess(AuthorizationHandler):
actions = ["can_read_facility"]
queries = ["allowed_facilities"]

Check warning on line 11 in care/security/controllers/facility.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/facility.py#L9-L11

Added lines #L9 - L11 were not covered by tests

def can_read_facility(self, user, facility_id):
self.check_permission(user, facility_id)

Check warning on line 14 in care/security/controllers/facility.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/facility.py#L13-L14

Added lines #L13 - L14 were not covered by tests
# Since the old method relied on a facility-user relationship, check that
# This can be removed when the migrations have been completed
if not FacilityUser.objects.filter(facility_id=facility_id, user=user).exists():
raise PermissionDeniedError
return True, True

Check warning on line 19 in care/security/controllers/facility.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/facility.py#L18-L19

Added lines #L18 - L19 were not covered by tests


def allowed_facilities(self , user):
return Facility.objects.filter(users__id__exact=user.id)

Check warning on line 23 in care/security/controllers/facility.py

View check run for this annotation

Codecov / codecov/patch

care/security/controllers/facility.py#L22-L23

Added lines #L22 - L23 were not covered by tests
Empty file.
Empty file.
65 changes: 65 additions & 0 deletions care/security/management/commands/sync_permissions_roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from django.core.management import BaseCommand
from django.db import transaction

Check warning on line 2 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L1-L2

Added lines #L1 - L2 were not covered by tests

from care.security.models import PermissionModel, RoleModel, RolePermission
from care.security.permissions.permissions import PermissionController
from care.security.roles.role import RoleController
from care.utils.lock import Lock

Check warning on line 7 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L4-L7

Added lines #L4 - L7 were not covered by tests


class Command(BaseCommand):

Check warning on line 10 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L10

Added line #L10 was not covered by tests
"""
This command syncs roles, permissions and role-permission mapping to the database.
This command should be run after all deployments and plug changes.
This command is idempotent, multiple instances running the same command is automatically blocked with redis.
"""

help = "Syncs permissions and roles to database"

Check warning on line 17 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L17

Added line #L17 was not covered by tests

def handle(self, *args, **options):
permissions = PermissionController.get_permissions()
roles = RoleController.get_roles()

Check warning on line 21 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L19-L21

Added lines #L19 - L21 were not covered by tests
with transaction.atomic(), Lock("sync_permissions_roles", 900):
# Create, update permissions and delete old permissions
PermissionModel.objects.all().update(temp_deleted=True)

Check warning on line 24 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L24

Added line #L24 was not covered by tests
for permission, metadata in permissions.items():
permission_obj = PermissionModel.objects.filter(slug=permission).first()

Check warning on line 26 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L26

Added line #L26 was not covered by tests
if not permission_obj:
permission_obj = PermissionModel(slug=permission)
permission_obj.name = metadata.name
permission_obj.description = metadata.description
permission_obj.context = metadata.context.value
permission_obj.temp_deleted = False
permission_obj.save()
PermissionModel.objects.filter(temp_deleted=True).delete()

Check warning on line 34 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L28-L34

Added lines #L28 - L34 were not covered by tests
# Create, update roles and delete old roles
RoleModel.objects.all().update(temp_deleted=True)

Check warning on line 36 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L36

Added line #L36 was not covered by tests
for role in roles:
role_obj = RoleModel.objects.filter(

Check warning on line 38 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L38

Added line #L38 was not covered by tests
name=role.name, context=role.context.value
).first()
if not role_obj:
role_obj = RoleModel(name=role.name, context=role.context.value)
role_obj.description = role.description
role_obj.is_system = True
role_obj.temp_deleted = False
role_obj.save()
RoleModel.objects.filter(temp_deleted=True).delete()

Check warning on line 47 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L42-L47

Added lines #L42 - L47 were not covered by tests
# Sync permissions to role
RolePermission.objects.all().update(temp_deleted=True)
role_cache = {}

Check warning on line 50 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L49-L50

Added lines #L49 - L50 were not covered by tests
for permission, metadata in permissions.items():
permission_obj = PermissionModel.objects.filter(slug=permission).first()

Check warning on line 52 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L52

Added line #L52 was not covered by tests
for role in metadata.roles:
if role.name not in role_cache:
role_cache[role.name] = RoleModel.objects.get(name=role.name)
obj = RolePermission.objects.filter(

Check warning on line 56 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L55-L56

Added lines #L55 - L56 were not covered by tests
role=role_cache[role.name], permission=permission_obj
).first()
if not obj:
obj = RolePermission(

Check warning on line 60 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L60

Added line #L60 was not covered by tests
role=role_cache[role.name], permission=permission_obj
)
obj.temp_deleted = False
obj.save()
RolePermission.objects.filter(temp_deleted=True).delete()

Check warning on line 65 in care/security/management/commands/sync_permissions_roles.py

View check run for this annotation

Codecov / codecov/patch

care/security/management/commands/sync_permissions_roles.py#L63-L65

Added lines #L63 - L65 were not covered by tests
88 changes: 88 additions & 0 deletions care/security/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Generated by Django 5.1.1 on 2024-10-01 17:03

import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='PermissionModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)),
('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)),
('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)),
('deleted', models.BooleanField(db_index=True, default=False)),
('slug', models.CharField(db_index=True, max_length=1024, unique=True)),
('name', models.CharField(max_length=1024)),
('description', models.TextField(default='')),
('context', models.CharField(max_length=1024)),
('temp_deleted', models.BooleanField(default=False)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='RoleModel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)),
('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)),
('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)),
('deleted', models.BooleanField(db_index=True, default=False)),
('name', models.CharField(max_length=1024)),
('description', models.TextField(default='')),
('context', models.CharField(max_length=1024)),
('is_system', models.BooleanField(default=False)),
('temp_deleted', models.BooleanField(default=False)),
],
options={
'unique_together': {('name', 'context')},
},
),
migrations.CreateModel(
name='RoleAssociation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)),
('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)),
('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)),
('deleted', models.BooleanField(db_index=True, default=False)),
('context', models.CharField(max_length=1024)),
('context_id', models.BigIntegerField()),
('expiry', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='security.rolemodel')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='RolePermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('external_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True)),
('created_date', models.DateTimeField(auto_now_add=True, db_index=True, null=True)),
('modified_date', models.DateTimeField(auto_now=True, db_index=True, null=True)),
('deleted', models.BooleanField(db_index=True, default=False)),
('temp_deleted', models.BooleanField(default=False)),
('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='security.permissionmodel')),
('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='security.rolemodel')),
],
options={
'abstract': False,
},
),
]
Empty file.
3 changes: 3 additions & 0 deletions care/security/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .permission import * # noqa F403
from .permission_association import * # noqa F403
from .role import * # noqa F403
18 changes: 18 additions & 0 deletions care/security/models/permission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.db import models

from care.utils.models.base import BaseModel


class PermissionModel(BaseModel):
"""
This model represents a permission in the security system.
A permission allows a certain action to be performed by the user for a given context.
"""

slug = models.CharField(max_length=1024, unique=True, db_index=True)
name = models.CharField(max_length=1024)
description = models.TextField(default="")
context = models.CharField(
max_length=1024
) # We can add choices here as well if needed
temp_deleted = models.BooleanField(default=False)
22 changes: 22 additions & 0 deletions care/security/models/permission_association.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from django.db import models

from care.security.models.role import RoleModel
from care.users.models import User
from care.utils.models.base import BaseModel


class RoleAssociation(BaseModel):
"""
This model connects roles to users via contexts
Expiry can be used to expire the role allocation after a certain period
"""

user = models.ForeignKey(User, on_delete=models.CASCADE, null=False, blank=False)
context = models.CharField(max_length=1024)
context_id = models.BigIntegerField() # Store integer id of the context here
role = models.ForeignKey(
RoleModel, on_delete=models.CASCADE, null=False, blank=False
)
expiry = models.DateTimeField(null=True, blank=True)

# TODO : Index user, context and context_id
42 changes: 42 additions & 0 deletions care/security/models/role.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from django.db import models

from care.security.models.permission import PermissionModel
from care.utils.models.base import BaseModel


class RoleModel(BaseModel):
"""
This model represents a role in the security system.
A role comprises multiple permissions on the same type.
A role can only be made for a single context. eg, A role can be FacilityAdmin with Facility related permission items
Another role is to be created for other contexts, eg. Asset Admin should only contain Asset related permission items
Roles can be created on the fly, System roles cannot be deleted, but user created roles can be deleted by users
with the permission to delete roles
"""

name = models.CharField(max_length=1024)
description = models.TextField(default="")
context = models.CharField(
max_length=1024
) # We can add choices here as well if needed
is_system = models.BooleanField(
default=False
) # Denotes if role was created on the fly
temp_deleted = models.BooleanField(default=False)

class Meta:
unique_together = ("name", "context")
vigneshhari marked this conversation as resolved.
Show resolved Hide resolved


class RolePermission(BaseModel):
"""
Connects a role to a list of permissions
"""

role = models.ForeignKey(
RoleModel, on_delete=models.CASCADE, null=False, blank=False
)
permission = models.ForeignKey(
PermissionModel, on_delete=models.CASCADE, null=False, blank=False
)
temp_deleted = models.BooleanField(default=False)
Empty file.
19 changes: 19 additions & 0 deletions care/security/permissions/facility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import enum

Check warning on line 1 in care/security/permissions/facility.py

View check run for this annotation

Codecov / codecov/patch

care/security/permissions/facility.py#L1

Added line #L1 was not covered by tests

from care.security.permissions.permissions import Permission, PermissionContext
from care.security.roles.role import DOCTOR_ROLE, STAFF_ROLE

Check warning on line 4 in care/security/permissions/facility.py

View check run for this annotation

Codecov / codecov/patch

care/security/permissions/facility.py#L3-L4

Added lines #L3 - L4 were not covered by tests


class FacilityPermissions(enum.Enum):
can_read_facility = Permission(

Check warning on line 8 in care/security/permissions/facility.py

View check run for this annotation

Codecov / codecov/patch

care/security/permissions/facility.py#L7-L8

Added lines #L7 - L8 were not covered by tests
"Can Read on Facility",
"Something Here",
PermissionContext.FACILITY,
[STAFF_ROLE, DOCTOR_ROLE],
)
can_update_facility = Permission(

Check warning on line 14 in care/security/permissions/facility.py

View check run for this annotation

Codecov / codecov/patch

care/security/permissions/facility.py#L14

Added line #L14 was not covered by tests
"Can Update on Facility",
"Something Here",
PermissionContext.FACILITY,
[STAFF_ROLE],
)
Loading
Loading