diff --git a/care/security/__init__.py b/care/security/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/apps.py b/care/security/apps.py new file mode 100644 index 0000000000..9f29b9bd7a --- /dev/null +++ b/care/security/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class SecurityConfig(AppConfig): + name = "care.security" + verbose_name = _("Security Management") + + def ready(self): + # import care.security.signals # noqa F401 + pass diff --git a/care/security/authorization/__init__.py b/care/security/authorization/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/authorization/base.py b/care/security/authorization/base.py new file mode 100644 index 0000000000..36db9c022e --- /dev/null +++ b/care/security/authorization/base.py @@ -0,0 +1,88 @@ +from care.security.permissions.base import PermissionController + + +class PermissionDeniedError(Exception): + pass + + +class AuthorizationHandler: + """ + 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 = [] + + def check_permission(self, user, obj): + if not PermissionController.has_permission(user, obj): + raise PermissionDeniedError + + return PermissionController.has_permission(user, obj) + + +class AuthorizationController: + """ + 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[ + AuthorizationHandler + ] = [] # The order is important + # Override Security Controllers will be defined from plugs + internal_authz_controllers: list[AuthorizationHandler] = [] + + cache = {} + + @classmethod + 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] = [ + *cls.cache["actions"].get(action, []), + controller, + ] + + @classmethod + def get_action_controllers(cls, action): + return cls.cache["actions"].get(action, []) + + @classmethod + 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) + for controller in controllers: + permission_fn = getattr(controller, action) + result, _continue = permission_fn(user, obj) + if not _continue: + return result + if not result: + return result + return True + + @classmethod + def register_internal_controller(cls, controller: AuthorizationHandler): + # TODO : Do some deduplication Logic + cls.internal_authz_controllers.append(controller) diff --git a/care/security/authorization/facility.py b/care/security/authorization/facility.py new file mode 100644 index 0000000000..11de91f2b7 --- /dev/null +++ b/care/security/authorization/facility.py @@ -0,0 +1,22 @@ +from care.abdm.utils.api_call import Facility +from care.facility.models import FacilityUser +from care.security.authorization.base import ( + AuthorizationHandler, + PermissionDeniedError, +) + + +class FacilityAccess(AuthorizationHandler): + actions = ["can_read_facility"] + queries = ["allowed_facilities"] + + def can_read_facility(self, user, facility_id): + self.check_permission(user, facility_id) + # 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 + + def allowed_facilities(self, user): + return Facility.objects.filter(users__id__exact=user.id) diff --git a/care/security/management/__init__.py b/care/security/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/management/commands/__init__.py b/care/security/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/management/commands/sync_permissions_roles.py b/care/security/management/commands/sync_permissions_roles.py new file mode 100644 index 0000000000..5d5808e7e7 --- /dev/null +++ b/care/security/management/commands/sync_permissions_roles.py @@ -0,0 +1,65 @@ +from django.core.management import BaseCommand +from django.db import transaction + +from care.security.models import PermissionModel, RoleModel, RolePermission +from care.security.permissions.base import PermissionController +from care.security.roles.role import RoleController +from care.utils.lock import Lock + + +class Command(BaseCommand): + """ + 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" + + def handle(self, *args, **options): + permissions = PermissionController.get_permissions() + roles = RoleController.get_roles() + with transaction.atomic(), Lock("sync_permissions_roles", 900): + # Create, update permissions and delete old permissions + PermissionModel.objects.all().update(temp_deleted=True) + for permission, metadata in permissions.items(): + permission_obj = PermissionModel.objects.filter(slug=permission).first() + 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() + # Create, update roles and delete old roles + RoleModel.objects.all().update(temp_deleted=True) + for role in roles: + role_obj = RoleModel.objects.filter( + 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() + # Sync permissions to role + RolePermission.objects.all().update(temp_deleted=True) + role_cache = {} + for permission, metadata in permissions.items(): + permission_obj = PermissionModel.objects.filter(slug=permission).first() + 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( + role=role_cache[role.name], permission=permission_obj + ).first() + if not obj: + obj = RolePermission( + role=role_cache[role.name], permission=permission_obj + ) + obj.temp_deleted = False + obj.save() + RolePermission.objects.filter(temp_deleted=True).delete() diff --git a/care/security/migrations/0001_initial.py b/care/security/migrations/0001_initial.py new file mode 100644 index 0000000000..37363e6084 --- /dev/null +++ b/care/security/migrations/0001_initial.py @@ -0,0 +1,88 @@ +# Generated by Django 5.1.2 on 2024-10-30 10:00 + +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={ + 'constraints': [models.UniqueConstraint(fields=('name', 'context'), name='unique_order')], + }, + ), + 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, + }, + ), + ] diff --git a/care/security/migrations/__init__.py b/care/security/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/models/__init__.py b/care/security/models/__init__.py new file mode 100644 index 0000000000..edaf462c2c --- /dev/null +++ b/care/security/models/__init__.py @@ -0,0 +1,3 @@ +from .permission import * # noqa F403 +from .permission_association import * # noqa F403 +from .role import * # noqa F403 diff --git a/care/security/models/permission.py b/care/security/models/permission.py new file mode 100644 index 0000000000..0faed59c6d --- /dev/null +++ b/care/security/models/permission.py @@ -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) diff --git a/care/security/models/permission_association.py b/care/security/models/permission_association.py new file mode 100644 index 0000000000..b8e85fd3b8 --- /dev/null +++ b/care/security/models/permission_association.py @@ -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 diff --git a/care/security/models/role.py b/care/security/models/role.py new file mode 100644 index 0000000000..471aa44d35 --- /dev/null +++ b/care/security/models/role.py @@ -0,0 +1,45 @@ +from django.db import models +from django.db.models import UniqueConstraint + +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: + constraints = [ + UniqueConstraint(name="unique_order", fields=["name", "context"]) + ] + + +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) diff --git a/care/security/permissions/__init__.py b/care/security/permissions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/permissions/base.py b/care/security/permissions/base.py new file mode 100644 index 0000000000..00028e6b39 --- /dev/null +++ b/care/security/permissions/base.py @@ -0,0 +1,83 @@ +import enum +from dataclasses import dataclass + +from care.security.models import RoleAssociation, RolePermission + + +class PermissionContext(enum.Enum): + GENERIC = "GENERIC" + FACILITY = "FACILITY" + ASSET = "ASSET" + LOCATION = "LOCATION" + + +@dataclass +class Permission: + """ + This class abstracts a permission + """ + + name: str + description: str + context: PermissionContext + roles: list + + +class PermissionHandler: + pass + + +from care.security.permissions.facility import FacilityPermissions # noqa: E402 + + +class PermissionController: + """ + This class defines all permissions used within care. + This class is used to abstract all operations related to permissions + """ + + override_permission_handlers = [] + # Override Permission Controllers will be defined from plugs + internal_permission_handlers = [FacilityPermissions] + + cache = {} + + @classmethod + def build_cache(cls): + """ + Iterate through the entire permission library and create a list of permissions and associated Metadata + """ + for handler in ( + cls.internal_permission_handlers + cls.override_permission_handlers + ): + for permission in handler: + cls.cache[permission.name] = permission.value + + @classmethod + def has_permission(cls, user, permission, context, context_id): + # TODO : Cache permissions and invalidate when they change + # TODO : Fetch the user role from the previous role management implementation as well. + # Need to maintain some sort of mapping from previous generation to new generation of roles + from care.security.roles.role import RoleController + + mapped_role = RoleController.map_old_role_to_new(user.role) + permission_roles = RolePermission.objects.filter( + permission__slug=permission, permission__context=context + ).values("role_id") + if RoleAssociation.objects.filter( + context_id=context_id, context=context, role__in=permission_roles, user=user + ).exists(): + return True + # Check for old cases + return RolePermission.objects.filter( + permission__slug=permission, + permission__context=context, + role__name=mapped_role.name, + role__context=mapped_role.context.value, + ).exists() + + @classmethod + def get_permissions(cls): + if not cls.cache: + cls.build_cache() + return cls.cache diff --git a/care/security/permissions/facility.py b/care/security/permissions/facility.py new file mode 100644 index 0000000000..ee1cafbd0e --- /dev/null +++ b/care/security/permissions/facility.py @@ -0,0 +1,19 @@ +import enum + +from care.security.permissions.base import Permission, PermissionContext +from care.security.roles.role import DOCTOR_ROLE, STAFF_ROLE + + +class FacilityPermissions(enum.Enum): + can_read_facility = Permission( + "Can Read on Facility", + "Something Here", + PermissionContext.FACILITY, + [STAFF_ROLE, DOCTOR_ROLE], + ) + can_update_facility = Permission( + "Can Update on Facility", + "Something Here", + PermissionContext.FACILITY, + [STAFF_ROLE], + ) diff --git a/care/security/roles/__init__.py b/care/security/roles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/roles/role.py b/care/security/roles/role.py new file mode 100644 index 0000000000..dac2d8455e --- /dev/null +++ b/care/security/roles/role.py @@ -0,0 +1,69 @@ +from dataclasses import dataclass + +from care.security.permissions.base import PermissionContext + + +@dataclass +class Role: + """ + This class can be inherited for role classes that are created by default + """ + + name: str + description: str + context: PermissionContext + + +DOCTOR_ROLE = Role( + name="Doctor", + description="Some Description Here", + context=PermissionContext.FACILITY, +) # TODO : Clean description +STAFF_ROLE = Role( + name="Staff", + description="Some Description Here", + context=PermissionContext.FACILITY, +) # TODO : Clean description +ADMIN_ROLE = Role( + name="Facility Admin", + description="Some Description Here", + context=PermissionContext.FACILITY, +) # TODO : Clean description + + +class RoleController: + override_roles = [] + # Override Permission Controllers will be defined from plugs + internal_roles = [DOCTOR_ROLE, STAFF_ROLE] + + @classmethod + def get_roles(cls): + return cls.internal_roles + cls.override_roles + + @classmethod + def map_old_role_to_new(cls, old_role): + mapping = { + "Transportation": STAFF_ROLE, + "Pharmacist": STAFF_ROLE, + "Volunteer": STAFF_ROLE, + "StaffReadOnly": STAFF_ROLE, + "Staff": STAFF_ROLE, + "NurseReadOnly": STAFF_ROLE, + "Nurse": STAFF_ROLE, + "Doctor": DOCTOR_ROLE, + "Reserved": DOCTOR_ROLE, + "WardAdmin": STAFF_ROLE, + "LocalBodyAdmin": ADMIN_ROLE, + "DistrictLabAdmin": ADMIN_ROLE, + "DistrictReadOnlyAdmin": ADMIN_ROLE, + "DistrictAdmin": ADMIN_ROLE, + "StateLabAdmin": ADMIN_ROLE, + "StateReadOnlyAdmin": ADMIN_ROLE, + "StateAdmin": ADMIN_ROLE, + } + return mapping[old_role] + + @classmethod + def register_role(cls, role: Role): + # TODO : Do some deduplication Logic + cls.override_roles.append(role) diff --git a/config/settings/base.py b/config/settings/base.py index df20ebb0cf..65e58d235d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -124,6 +124,7 @@ "healthy_django", ] LOCAL_APPS = [ + "care.security", "care.facility", "care.users", "care.audit_log",