From 6307ebc8f1a68f075d808e6d39c0cf2fdebaf6f7 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Fri, 27 Sep 2024 00:18:23 +0530 Subject: [PATCH 01/12] Added Basics --- care/security/__init__.py | 0 care/security/apps.py | 11 ++++++ care/security/models/__init__.py | 0 care/security/models/permission.py | 14 +++++++ .../security/models/permission_association.py | 19 ++++++++++ care/security/models/role.py | 27 +++++++++++++ care/security/permissions.py | 38 +++++++++++++++++++ care/security/roles.py | 10 +++++ care/security/security_controller.py | 27 +++++++++++++ config/settings/base.py | 1 + 10 files changed, 147 insertions(+) create mode 100644 care/security/__init__.py create mode 100644 care/security/apps.py create mode 100644 care/security/models/__init__.py create mode 100644 care/security/models/permission.py create mode 100644 care/security/models/permission_association.py create mode 100644 care/security/models/role.py create mode 100644 care/security/permissions.py create mode 100644 care/security/roles.py create mode 100644 care/security/security_controller.py 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..4f265ef321 --- /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 FacilityConfig(AppConfig): + name = "care.security" + verbose_name = _("Security Management") + + def ready(self): + #import care.security.signals # noqa F401 + pass diff --git a/care/security/models/__init__.py b/care/security/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/models/permission.py b/care/security/models/permission.py new file mode 100644 index 0000000000..e8caff366e --- /dev/null +++ b/care/security/models/permission.py @@ -0,0 +1,14 @@ + +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. + """ + 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 diff --git a/care/security/models/permission_association.py b/care/security/models/permission_association.py new file mode 100644 index 0000000000..8aedba5352 --- /dev/null +++ b/care/security/models/permission_association.py @@ -0,0 +1,19 @@ +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..4a089aab4e --- /dev/null +++ b/care/security/models/role.py @@ -0,0 +1,27 @@ +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 + + +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) diff --git a/care/security/permissions.py b/care/security/permissions.py new file mode 100644 index 0000000000..08e78dc8a9 --- /dev/null +++ b/care/security/permissions.py @@ -0,0 +1,38 @@ +import enum +from dataclasses import dataclass + + +class PermissionContext(enum.Enum): + GENERIC = "GENERIC" + FACILITY = "FACILITY" + ASSET = "ASSET" + + +@dataclass +class Permission: + """ + This class abstracts a permission + """ + permission_name: str + permission_description: str + permission_context: PermissionContext + roles: list + + +class InternalPermissionController: + pass + + +class ExternalPermissionController: + pass + + +class PermissionController: + """ + This class defines all permissions used within care. + This class is used to abstract all operations related to permissions + """ + + OVERRIDE_PERMISSION_CONTROLLERS = [] + # Override Permission Controllers will be defined from plugs + INTERNAL_PERMISSION_CONTROLLERS = [] diff --git a/care/security/roles.py b/care/security/roles.py new file mode 100644 index 0000000000..a048d7b95e --- /dev/null +++ b/care/security/roles.py @@ -0,0 +1,10 @@ + +class DefaultRole: + """ + This class can be inherited for role classes that are created by default + """ + +class DoctorRole(DefaultRole): + """ + Role Assigned to a doctor in a given facility + """ diff --git a/care/security/security_controller.py b/care/security/security_controller.py new file mode 100644 index 0000000000..b01f947a84 --- /dev/null +++ b/care/security/security_controller.py @@ -0,0 +1,27 @@ +class OverrideSecurityController: + """ + Inherit this class and redefine security operations to reconfigure permission management + """ + + +class InternalSecurityController: + """ + This class is strictly to be used within care, This class was built to separate logic + for permission control across different files + """ + + +class SecurityController: + """ + 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. + """ + + OVERRIDE_SECURITY_CONTROLLERS = [] # The order is important + # Override Security Controllers will be defined from plugs + INTERNAL_SECURITY_CONTROLLERS = [] diff --git a/config/settings/base.py b/config/settings/base.py index 5bf8ddd3b6..a30f9e05b4 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -124,6 +124,7 @@ "healthy_django", ] LOCAL_APPS = [ + "care.security", "care.facility", "care.abdm", "care.users", From 9f98f332ac75be554733b4e5528724c7b6605949 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Sun, 29 Sep 2024 00:27:13 +0530 Subject: [PATCH 02/12] Restructuring --- care/security/controllers/__init__.py | 0 .../controller.py} | 1 + care/security/permission/__init__.py | 0 care/security/{ => permission}/permissions.py | 4 ++-- care/security/roles.py | 10 -------- care/security/roles/__init__.py | 0 care/security/roles/role.py | 23 +++++++++++++++++++ 7 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 care/security/controllers/__init__.py rename care/security/{security_controller.py => controllers/controller.py} (91%) create mode 100644 care/security/permission/__init__.py rename care/security/{ => permission}/permissions.py (89%) delete mode 100644 care/security/roles.py create mode 100644 care/security/roles/__init__.py create mode 100644 care/security/roles/role.py diff --git a/care/security/controllers/__init__.py b/care/security/controllers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/security_controller.py b/care/security/controllers/controller.py similarity index 91% rename from care/security/security_controller.py rename to care/security/controllers/controller.py index b01f947a84..c4bca4f3b9 100644 --- a/care/security/security_controller.py +++ b/care/security/controllers/controller.py @@ -20,6 +20,7 @@ class SecurityController: 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_SECURITY_CONTROLLERS = [] # The order is important diff --git a/care/security/permission/__init__.py b/care/security/permission/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/security/permissions.py b/care/security/permission/permissions.py similarity index 89% rename from care/security/permissions.py rename to care/security/permission/permissions.py index 08e78dc8a9..4cd02a11a9 100644 --- a/care/security/permissions.py +++ b/care/security/permission/permissions.py @@ -33,6 +33,6 @@ class PermissionController: This class is used to abstract all operations related to permissions """ - OVERRIDE_PERMISSION_CONTROLLERS = [] + OVERRIDE_PERMISSIONS = [] # Override Permission Controllers will be defined from plugs - INTERNAL_PERMISSION_CONTROLLERS = [] + INTERNAL_PERMISSIONS = [] diff --git a/care/security/roles.py b/care/security/roles.py deleted file mode 100644 index a048d7b95e..0000000000 --- a/care/security/roles.py +++ /dev/null @@ -1,10 +0,0 @@ - -class DefaultRole: - """ - This class can be inherited for role classes that are created by default - """ - -class DoctorRole(DefaultRole): - """ - Role Assigned to a doctor in a given facility - """ 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..b3b7067f6d --- /dev/null +++ b/care/security/roles/role.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + + +@dataclass +class NewRole: + """ + This class can be inherited for role classes that are created by default + """ + name: str + description: str + + +DOCTOR_ROLE = NewRole(name="Doctor", description="Some Description Here") # TODO : Clean description + + +class Role: + OVERRIDE_PERMISSION_CONTROLLERS = [] + # Override Permission Controllers will be defined from plugs + INTERNAL_PERMISSION_CONTROLLERS = [DOCTOR_ROLE] + + @classmethod + def get_roles(cls): + return cls.INTERNAL_PERMISSION_CONTROLLERS + cls.OVERRIDE_PERMISSION_CONTROLLERS From 189c7efce29ad8000d90db1f0915c0c80ca328b1 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Mon, 30 Sep 2024 01:19:41 +0530 Subject: [PATCH 03/12] Cleaning up codebase --- care/security/controllers/controller.py | 63 +++++++++++++++++++++---- care/security/permission/facility.py | 7 +++ care/security/permission/permissions.py | 24 ++++++++-- care/security/roles/role.py | 17 ++++--- 4 files changed, 90 insertions(+), 21 deletions(-) create mode 100644 care/security/permission/facility.py diff --git a/care/security/controllers/controller.py b/care/security/controllers/controller.py index c4bca4f3b9..9b1a042a76 100644 --- a/care/security/controllers/controller.py +++ b/care/security/controllers/controller.py @@ -1,17 +1,23 @@ -class OverrideSecurityController: - """ - Inherit this class and redefine security operations to reconfigure permission management - """ +from collections import defaultdict -class InternalSecurityController: +class AuthorizationHandler: """ - This class is strictly to be used within care, This class was built to separate logic - for permission control across different files + 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 = [] -class SecurityController: +class AuthorizationController: """ This class abstracts all security related operations in care This includes Checking if A has access to resource X, @@ -23,6 +29,43 @@ class SecurityController: The overridden classes can choose to call the next function in the hierarchy if needed. """ - OVERRIDE_SECURITY_CONTROLLERS = [] # The order is important + override_authz_controllers: list[AuthorizationHandler] = [] # The order is important # Override Security Controllers will be defined from plugs - INTERNAL_SECURITY_CONTROLLERS = [] + 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/permission/facility.py b/care/security/permission/facility.py new file mode 100644 index 0000000000..c31070cd80 --- /dev/null +++ b/care/security/permission/facility.py @@ -0,0 +1,7 @@ +import enum + +from care.security.permission.permissions import Permission, PermissionContext + + +class FacilityPermissions(enum.Enum): + can_read_facility = Permission("Can Read on Facility", "", PermissionContext.FACILITY, []) diff --git a/care/security/permission/permissions.py b/care/security/permission/permissions.py index 4cd02a11a9..d6a668348b 100644 --- a/care/security/permission/permissions.py +++ b/care/security/permission/permissions.py @@ -6,6 +6,7 @@ class PermissionContext(enum.Enum): GENERIC = "GENERIC" FACILITY = "FACILITY" ASSET = "ASSET" + LOCATION = "LOCATION" @dataclass @@ -19,12 +20,11 @@ class Permission: roles: list -class InternalPermissionController: +class PermissionHandler: pass -class ExternalPermissionController: - pass +from .facility import FacilityPermissions # noqa: E402 class PermissionController: @@ -33,6 +33,20 @@ class PermissionController: This class is used to abstract all operations related to permissions """ - OVERRIDE_PERMISSIONS = [] + override_permission_handlers = [] # Override Permission Controllers will be defined from plugs - INTERNAL_PERMISSIONS = [] + 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 + """ + pass + + @classmethod + def has_permission(cls, user, permission): + # TODO : Can Cache Directly + pass diff --git a/care/security/roles/role.py b/care/security/roles/role.py index b3b7067f6d..92cf731150 100644 --- a/care/security/roles/role.py +++ b/care/security/roles/role.py @@ -2,7 +2,7 @@ @dataclass -class NewRole: +class Role: """ This class can be inherited for role classes that are created by default """ @@ -10,14 +10,19 @@ class NewRole: description: str -DOCTOR_ROLE = NewRole(name="Doctor", description="Some Description Here") # TODO : Clean description +DOCTOR_ROLE = Role(name="Doctor", description="Some Description Here") # TODO : Clean description -class Role: - OVERRIDE_PERMISSION_CONTROLLERS = [] +class RoleController: + override_roles = [] # Override Permission Controllers will be defined from plugs - INTERNAL_PERMISSION_CONTROLLERS = [DOCTOR_ROLE] + internal_roles = [DOCTOR_ROLE] @classmethod def get_roles(cls): - return cls.INTERNAL_PERMISSION_CONTROLLERS + cls.OVERRIDE_PERMISSION_CONTROLLERS + return cls.internal_roles + cls.override_roles + + @classmethod + def register_role(cls, role: Role): + # TODO : Do some deduplication Logic + cls.override_roles.append(role) From 5acfd9bf74a496bcc8eb25854ded8535996c6e21 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Tue, 1 Oct 2024 16:24:41 +0530 Subject: [PATCH 04/12] Add migrations, sync commands and rest --- .../{permission => management}/__init__.py | 0 .../commands/sync_permissions_roles.py | 61 +++++++++++++ care/security/migrations/0001_initial.py | 88 +++++++++++++++++++ care/security/migrations/__init__.py | 0 care/security/models/__init__.py | 3 + care/security/models/permission.py | 2 + care/security/models/role.py | 4 +- care/security/permission/facility.py | 7 -- care/security/permissions/__init__.py | 0 care/security/permissions/facility.py | 9 ++ .../permissions.py | 20 +++-- care/security/roles/role.py | 10 ++- 12 files changed, 188 insertions(+), 16 deletions(-) rename care/security/{permission => management}/__init__.py (100%) create mode 100644 care/security/management/commands/sync_permissions_roles.py create mode 100644 care/security/migrations/0001_initial.py create mode 100644 care/security/migrations/__init__.py delete mode 100644 care/security/permission/facility.py create mode 100644 care/security/permissions/__init__.py create mode 100644 care/security/permissions/facility.py rename care/security/{permission => permissions}/permissions.py (63%) diff --git a/care/security/permission/__init__.py b/care/security/management/__init__.py similarity index 100% rename from care/security/permission/__init__.py rename to care/security/management/__init__.py 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..7a87733606 --- /dev/null +++ b/care/security/management/commands/sync_permissions_roles.py @@ -0,0 +1,61 @@ + +from django.core.management import BaseCommand + +from care.security.models import PermissionModel, RoleModel, RolePermission +from care.security.permissions.permissions import PermissionController +from care.security.roles.role import RoleController +from django.db import transaction + +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).first() + if not role_obj: + role_obj = RoleModel(name=role.name) + role_obj.description = role.description + role_obj.context = role.context.value + 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..8cc3b26626 --- /dev/null +++ b/care/security/migrations/0001_initial.py @@ -0,0 +1,88 @@ +# Generated by Django 5.1.1 on 2024-10-01 10:53 + +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, unique=True)), + ('description', models.TextField(default='')), + ('context', models.CharField(max_length=1024)), + ('is_system', models.BooleanField(default=False)), + ('temp_deleted', models.BooleanField(default=False)), + ], + options={ + 'abstract': False, + }, + ), + 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 index e69de29bb2..39770c97cf 100644 --- a/care/security/models/__init__.py +++ b/care/security/models/__init__.py @@ -0,0 +1,3 @@ +from .permission import * +from .permission_association import * +from .role import * diff --git a/care/security/models/permission.py b/care/security/models/permission.py index e8caff366e..09b9f0f993 100644 --- a/care/security/models/permission.py +++ b/care/security/models/permission.py @@ -9,6 +9,8 @@ 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/role.py b/care/security/models/role.py index 4a089aab4e..043e147c24 100644 --- a/care/security/models/role.py +++ b/care/security/models/role.py @@ -13,10 +13,11 @@ class RoleModel(BaseModel): 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) + name = models.CharField(max_length=1024 , unique=True) 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 RolePermission(BaseModel): @@ -25,3 +26,4 @@ class RolePermission(BaseModel): """ 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/permission/facility.py b/care/security/permission/facility.py deleted file mode 100644 index c31070cd80..0000000000 --- a/care/security/permission/facility.py +++ /dev/null @@ -1,7 +0,0 @@ -import enum - -from care.security.permission.permissions import Permission, PermissionContext - - -class FacilityPermissions(enum.Enum): - can_read_facility = Permission("Can Read on Facility", "", PermissionContext.FACILITY, []) 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/facility.py b/care/security/permissions/facility.py new file mode 100644 index 0000000000..48f5799096 --- /dev/null +++ b/care/security/permissions/facility.py @@ -0,0 +1,9 @@ +import enum + +from care.security.permissions.permissions import Permission, PermissionContext +from care.security.roles.role import STAFF_ROLE + + +class FacilityPermissions(enum.Enum): + can_read_facility = Permission("Can Read on Facility", "Something Here", PermissionContext.FACILITY, [STAFF_ROLE]) + can_update_facility = Permission("Can Update on Facility", "Something Here", PermissionContext.FACILITY, [STAFF_ROLE]) diff --git a/care/security/permission/permissions.py b/care/security/permissions/permissions.py similarity index 63% rename from care/security/permission/permissions.py rename to care/security/permissions/permissions.py index d6a668348b..9cab7f2256 100644 --- a/care/security/permission/permissions.py +++ b/care/security/permissions/permissions.py @@ -14,9 +14,9 @@ class Permission: """ This class abstracts a permission """ - permission_name: str - permission_description: str - permission_context: PermissionContext + name: str + description: str + context: PermissionContext roles: list @@ -24,7 +24,7 @@ class PermissionHandler: pass -from .facility import FacilityPermissions # noqa: E402 +from care.security.permissions.facility import FacilityPermissions # noqa: E402 class PermissionController: @@ -44,9 +44,17 @@ def build_cache(cls): """ Iterate through the entire permission library and create a list of permissions and associated Metadata """ - pass + 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): - # TODO : Can Cache Directly + # TODO : Cache permissions and invalidate when they change pass + + @classmethod + def get_permissions(cls): + if not cls.cache: + cls.build_cache() + return cls.cache diff --git a/care/security/roles/role.py b/care/security/roles/role.py index 92cf731150..05c6770cb1 100644 --- a/care/security/roles/role.py +++ b/care/security/roles/role.py @@ -1,5 +1,7 @@ from dataclasses import dataclass +from care.security.permissions.permissions import PermissionContext + @dataclass class Role: @@ -8,15 +10,19 @@ class Role: """ name: str description: str + context: PermissionContext -DOCTOR_ROLE = Role(name="Doctor", description="Some Description Here") # TODO : Clean description +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 class RoleController: override_roles = [] # Override Permission Controllers will be defined from plugs - internal_roles = [DOCTOR_ROLE] + internal_roles = [DOCTOR_ROLE, STAFF_ROLE] @classmethod def get_roles(cls): From f9a714573fee66ed99f5c9daa544cdbd31e2259b Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Tue, 1 Oct 2024 16:30:44 +0530 Subject: [PATCH 05/12] Add permission controller logic --- .../security/management/commands/sync_permissions_roles.py | 3 +++ care/security/permissions/permissions.py | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/care/security/management/commands/sync_permissions_roles.py b/care/security/management/commands/sync_permissions_roles.py index 7a87733606..eb04470e77 100644 --- a/care/security/management/commands/sync_permissions_roles.py +++ b/care/security/management/commands/sync_permissions_roles.py @@ -59,3 +59,6 @@ def handle(self, *args, **options): obj.temp_deleted = False obj.save() RolePermission.objects.filter(temp_deleted=True).delete() + + + PermissionController.has_permission("tser" , "can_read_facility" , "FACILITY" , 1) diff --git a/care/security/permissions/permissions.py b/care/security/permissions/permissions.py index 9cab7f2256..eca1d99f01 100644 --- a/care/security/permissions/permissions.py +++ b/care/security/permissions/permissions.py @@ -1,6 +1,8 @@ import enum from dataclasses import dataclass +from care.security.models import RolePermission, RoleAssociation + class PermissionContext(enum.Enum): GENERIC = "GENERIC" @@ -49,9 +51,10 @@ def build_cache(cls): cls.cache[permission.name] = permission.value @classmethod - def has_permission(cls, user, permission): + def has_permission(cls, user, permission, context, context_id): # TODO : Cache permissions and invalidate when they change - pass + permission_roles = RolePermission.objects.filter(permission__slug=permission , permission__context=context).values("role_id") + return RoleAssociation.objects.filter(context_id=context_id , context=context, role__in=permission_roles).exists() @classmethod def get_permissions(cls): From b4f5a4ab4ac579ba122fbbdd5efd409394f0d27a Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Tue, 1 Oct 2024 21:36:54 +0530 Subject: [PATCH 06/12] Fix Linting --- care/security/controllers/controller.py | 1 - care/security/management/commands/__init__.py | 0 care/security/management/commands/sync_permissions_roles.py | 4 ++-- care/security/models/__init__.py | 6 +++--- care/security/permissions/permissions.py | 6 ++++-- 5 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 care/security/management/commands/__init__.py diff --git a/care/security/controllers/controller.py b/care/security/controllers/controller.py index 9b1a042a76..176515e473 100644 --- a/care/security/controllers/controller.py +++ b/care/security/controllers/controller.py @@ -1,4 +1,3 @@ -from collections import defaultdict class AuthorizationHandler: 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 index eb04470e77..92f27ed23f 100644 --- a/care/security/management/commands/sync_permissions_roles.py +++ b/care/security/management/commands/sync_permissions_roles.py @@ -1,13 +1,13 @@ from django.core.management import BaseCommand +from django.db import transaction from care.security.models import PermissionModel, RoleModel, RolePermission from care.security.permissions.permissions import PermissionController from care.security.roles.role import RoleController -from django.db import transaction - from care.utils.lock import Lock + class Command(BaseCommand): """ This command syncs roles, permissions and role-permission mapping to the database. diff --git a/care/security/models/__init__.py b/care/security/models/__init__.py index 39770c97cf..e126b51074 100644 --- a/care/security/models/__init__.py +++ b/care/security/models/__init__.py @@ -1,3 +1,3 @@ -from .permission import * -from .permission_association import * -from .role import * +from .permission import * # noqa F403 +from .permission_association import * # noqa F403 +from .role import * # noqa F403 diff --git a/care/security/permissions/permissions.py b/care/security/permissions/permissions.py index eca1d99f01..cb9b41588a 100644 --- a/care/security/permissions/permissions.py +++ b/care/security/permissions/permissions.py @@ -1,7 +1,7 @@ import enum from dataclasses import dataclass -from care.security.models import RolePermission, RoleAssociation +from care.security.models import RoleAssociation, RolePermission class PermissionContext(enum.Enum): @@ -53,8 +53,10 @@ def build_cache(cls): @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 permission_roles = RolePermission.objects.filter(permission__slug=permission , permission__context=context).values("role_id") - return RoleAssociation.objects.filter(context_id=context_id , context=context, role__in=permission_roles).exists() + return RoleAssociation.objects.filter(context_id=context_id , context=context, role__in=permission_roles , user=user).exists() @classmethod def get_permissions(cls): From e10b2496cfb73c70b1a6f25908298ab3e083b65d Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Tue, 1 Oct 2024 22:20:06 +0530 Subject: [PATCH 07/12] Add more permission stuff --- care/security/controllers/controller.py | 9 +++++++ care/security/controllers/facility.py | 15 +++++++++++ .../commands/sync_permissions_roles.py | 7 +++-- care/security/models/role.py | 5 +++- care/security/permissions/facility.py | 4 +-- care/security/permissions/permissions.py | 11 +++++++- care/security/roles/role.py | 26 +++++++++++++++++++ 7 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 care/security/controllers/facility.py diff --git a/care/security/controllers/controller.py b/care/security/controllers/controller.py index 176515e473..6d83bd618d 100644 --- a/care/security/controllers/controller.py +++ b/care/security/controllers/controller.py @@ -1,4 +1,7 @@ +from care.security.permissions.permissions import PermissionController +class PermissionDenied(Exception): + pass class AuthorizationHandler: """ @@ -16,6 +19,12 @@ class AuthorizationHandler: queries = [] + def check_permission(self, user, obj): + if not PermissionController.has_permission(user,obj): + raise PermissionDenied("Access to this resource is denied") + + return PermissionController.has_permission(user,obj) + class AuthorizationController: """ This class abstracts all security related operations in care diff --git a/care/security/controllers/facility.py b/care/security/controllers/facility.py new file mode 100644 index 0000000000..fb65b69d22 --- /dev/null +++ b/care/security/controllers/facility.py @@ -0,0 +1,15 @@ +from care.facility.models import FacilityUser +from care.security.controllers.controller import AuthorizationHandler, PermissionDenied + + +class FacilityAccess(AuthorizationHandler): + + actions = ["can_read_facility"] + + 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 PermissionDenied("Access to this facility is denied") + return True, True diff --git a/care/security/management/commands/sync_permissions_roles.py b/care/security/management/commands/sync_permissions_roles.py index 92f27ed23f..5d889d8bf7 100644 --- a/care/security/management/commands/sync_permissions_roles.py +++ b/care/security/management/commands/sync_permissions_roles.py @@ -1,3 +1,4 @@ +from lib2to3.fixes.fix_input import context from django.core.management import BaseCommand from django.db import transaction @@ -36,11 +37,10 @@ def handle(self, *args, **options): # 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).first() + role_obj = RoleModel.objects.filter(name=role.name , context = role.context.value).first() if not role_obj: - role_obj = RoleModel(name=role.name) + role_obj = RoleModel(name=role.name, context=role.context.value) role_obj.description = role.description - role_obj.context = role.context.value role_obj.is_system = True role_obj.temp_deleted = False role_obj.save() @@ -61,4 +61,3 @@ def handle(self, *args, **options): RolePermission.objects.filter(temp_deleted=True).delete() - PermissionController.has_permission("tser" , "can_read_facility" , "FACILITY" , 1) diff --git a/care/security/models/role.py b/care/security/models/role.py index 043e147c24..f30b4138e0 100644 --- a/care/security/models/role.py +++ b/care/security/models/role.py @@ -13,12 +13,15 @@ class RoleModel(BaseModel): 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 , unique=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 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") + class RolePermission(BaseModel): """ diff --git a/care/security/permissions/facility.py b/care/security/permissions/facility.py index 48f5799096..c581e7c6ba 100644 --- a/care/security/permissions/facility.py +++ b/care/security/permissions/facility.py @@ -1,9 +1,9 @@ import enum from care.security.permissions.permissions import Permission, PermissionContext -from care.security.roles.role import STAFF_ROLE +from care.security.roles.role import STAFF_ROLE, DOCTOR_ROLE class FacilityPermissions(enum.Enum): - can_read_facility = Permission("Can Read on Facility", "Something Here", PermissionContext.FACILITY, [STAFF_ROLE]) + 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/permissions/permissions.py b/care/security/permissions/permissions.py index cb9b41588a..4398fae958 100644 --- a/care/security/permissions/permissions.py +++ b/care/security/permissions/permissions.py @@ -4,6 +4,7 @@ from care.security.models import RoleAssociation, RolePermission + class PermissionContext(enum.Enum): GENERIC = "GENERIC" FACILITY = "FACILITY" @@ -50,13 +51,21 @@ def build_cache(cls): 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") - return RoleAssociation.objects.filter(context_id=context_id , context=context, role__in=permission_roles , user=user).exists() + if RoleAssociation.objects.filter(context_id=context_id , context=context, role__in=permission_roles , user=user).exists(): + return True + # Check for old cases + if RolePermission.objects.filter(permission__slug=permission , permission__context=context , role__name=mapped_role.name , role__context=mapped_role.context.value).exists(): + return True + return False @classmethod def get_permissions(cls): diff --git a/care/security/roles/role.py b/care/security/roles/role.py index 05c6770cb1..3931078f43 100644 --- a/care/security/roles/role.py +++ b/care/security/roles/role.py @@ -17,6 +17,8 @@ class Role: 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: @@ -28,6 +30,30 @@ class RoleController: 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 From d884b2cf98a4489fb107c77d409d58a834b5547b Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Tue, 1 Oct 2024 22:45:19 +0530 Subject: [PATCH 08/12] Add more permission stuff --- care/security/apps.py | 2 +- care/security/controllers/controller.py | 27 ++++++--- care/security/controllers/facility.py | 12 ++-- .../commands/sync_permissions_roles.py | 22 +++---- care/security/migrations/0001_initial.py | 6 +- care/security/models/__init__.py | 6 +- care/security/models/permission.py | 6 +- .../security/models/permission_association.py | 5 +- care/security/models/role.py | 18 ++++-- care/security/permissions/facility.py | 16 ++++- care/security/permissions/permissions.py | 25 +++++--- care/security/roles/role.py | 59 +++++++++++-------- 12 files changed, 130 insertions(+), 74 deletions(-) diff --git a/care/security/apps.py b/care/security/apps.py index 4f265ef321..a7f3e9420c 100644 --- a/care/security/apps.py +++ b/care/security/apps.py @@ -7,5 +7,5 @@ class FacilityConfig(AppConfig): verbose_name = _("Security Management") def ready(self): - #import care.security.signals # noqa F401 + # import care.security.signals # noqa F401 pass diff --git a/care/security/controllers/controller.py b/care/security/controllers/controller.py index 6d83bd618d..bd1af1013b 100644 --- a/care/security/controllers/controller.py +++ b/care/security/controllers/controller.py @@ -1,8 +1,10 @@ from care.security.permissions.permissions import PermissionController -class PermissionDenied(Exception): + +class PermissionDeniedError(Exception): pass + class AuthorizationHandler: """ This is the base class for Authorization Handlers @@ -15,15 +17,16 @@ class AuthorizationHandler: 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 PermissionDenied("Access to this resource is denied") + if not PermissionController.has_permission(user, obj): + raise PermissionDeniedError + + return PermissionController.has_permission(user, obj) - return PermissionController.has_permission(user,obj) class AuthorizationController: """ @@ -37,7 +40,9 @@ class AuthorizationController: 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_authz_controllers: list[ + AuthorizationHandler + ] = [] # The order is important # Override Security Controllers will be defined from plugs internal_authz_controllers: list[AuthorizationHandler] = [] @@ -45,11 +50,16 @@ class AuthorizationController: @classmethod def build_cache(cls): - for controller in cls.internal_authz_controllers + cls.override_authz_controllers: + 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] + cls.cache["actions"][action] = [ + *cls.cache["actions"].get(action, []), + controller, + ] @classmethod def get_action_controllers(cls, action): @@ -72,7 +82,6 @@ def check_action_permission(cls, action, user, obj): return result return True - @classmethod def register_internal_controller(cls, controller: AuthorizationHandler): # TODO : Do some deduplication Logic diff --git a/care/security/controllers/facility.py b/care/security/controllers/facility.py index fb65b69d22..58b1a1a5dd 100644 --- a/care/security/controllers/facility.py +++ b/care/security/controllers/facility.py @@ -1,15 +1,17 @@ from care.facility.models import FacilityUser -from care.security.controllers.controller import AuthorizationHandler, PermissionDenied +from care.security.controllers.controller import ( + AuthorizationHandler, + PermissionDeniedError, +) class FacilityAccess(AuthorizationHandler): - actions = ["can_read_facility"] - def can_read_facility(self , user, facility_id): + 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 PermissionDenied("Access to this facility is denied") + if not FacilityUser.objects.filter(facility_id=facility_id, user=user).exists(): + raise PermissionDeniedError return True, True diff --git a/care/security/management/commands/sync_permissions_roles.py b/care/security/management/commands/sync_permissions_roles.py index 5d889d8bf7..c8e72cfe26 100644 --- a/care/security/management/commands/sync_permissions_roles.py +++ b/care/security/management/commands/sync_permissions_roles.py @@ -1,5 +1,3 @@ -from lib2to3.fixes.fix_input import context - from django.core.management import BaseCommand from django.db import transaction @@ -21,10 +19,10 @@ class Command(BaseCommand): def handle(self, *args, **options): permissions = PermissionController.get_permissions() roles = RoleController.get_roles() - with transaction.atomic() , Lock("sync_permissions_roles",900): + 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(): + for permission, metadata in permissions.items(): permission_obj = PermissionModel.objects.filter(slug=permission).first() if not permission_obj: permission_obj = PermissionModel(slug=permission) @@ -37,7 +35,9 @@ def handle(self, *args, **options): # 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() + 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 @@ -48,16 +48,18 @@ def handle(self, *args, **options): # Sync permissions to role RolePermission.objects.all().update(temp_deleted=True) role_cache = {} - for permission,metadata in permissions.items(): + 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() + 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 = 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 index 8cc3b26626..524cabbd4c 100644 --- a/care/security/migrations/0001_initial.py +++ b/care/security/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.1 on 2024-10-01 10:53 +# Generated by Django 5.1.1 on 2024-10-01 17:03 import django.db.models.deletion import uuid @@ -41,14 +41,14 @@ class Migration(migrations.Migration): ('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, unique=True)), + ('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={ - 'abstract': False, + 'unique_together': {('name', 'context')}, }, ), migrations.CreateModel( diff --git a/care/security/models/__init__.py b/care/security/models/__init__.py index e126b51074..edaf462c2c 100644 --- a/care/security/models/__init__.py +++ b/care/security/models/__init__.py @@ -1,3 +1,3 @@ -from .permission import * # noqa F403 -from .permission_association import * # noqa F403 -from .role import * # noqa F403 +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 index 09b9f0f993..0faed59c6d 100644 --- a/care/security/models/permission.py +++ b/care/security/models/permission.py @@ -1,4 +1,3 @@ - from django.db import models from care.utils.models.base import BaseModel @@ -9,8 +8,11 @@ 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 + 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 index 8aedba5352..b8e85fd3b8 100644 --- a/care/security/models/permission_association.py +++ b/care/security/models/permission_association.py @@ -10,10 +10,13 @@ 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) + 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 index f30b4138e0..cae560bd5f 100644 --- a/care/security/models/role.py +++ b/care/security/models/role.py @@ -13,10 +13,15 @@ class RoleModel(BaseModel): 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 + 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: @@ -27,6 +32,11 @@ 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) + + 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/facility.py b/care/security/permissions/facility.py index c581e7c6ba..b39c116c83 100644 --- a/care/security/permissions/facility.py +++ b/care/security/permissions/facility.py @@ -1,9 +1,19 @@ import enum from care.security.permissions.permissions import Permission, PermissionContext -from care.security.roles.role import STAFF_ROLE, DOCTOR_ROLE +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]) + 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/permissions/permissions.py b/care/security/permissions/permissions.py index 4398fae958..00028e6b39 100644 --- a/care/security/permissions/permissions.py +++ b/care/security/permissions/permissions.py @@ -4,7 +4,6 @@ from care.security.models import RoleAssociation, RolePermission - class PermissionContext(enum.Enum): GENERIC = "GENERIC" FACILITY = "FACILITY" @@ -17,6 +16,7 @@ class Permission: """ This class abstracts a permission """ + name: str description: str context: PermissionContext @@ -47,25 +47,34 @@ 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 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(): + 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 - if RolePermission.objects.filter(permission__slug=permission , permission__context=context , role__name=mapped_role.name , role__context=mapped_role.context.value).exists(): - return True - return False + 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): diff --git a/care/security/roles/role.py b/care/security/roles/role.py index 3931078f43..40b9e81d34 100644 --- a/care/security/roles/role.py +++ b/care/security/roles/role.py @@ -8,17 +8,27 @@ 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 +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: @@ -30,27 +40,26 @@ class RoleController: def get_roles(cls): return cls.internal_roles + cls.override_roles - @classmethod - def map_old_role_to_new(cls , old_role): + 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, + "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] From 17c7ec28790fc2f4ac6bf11a838200084bc7f4a6 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Wed, 2 Oct 2024 22:11:02 +0530 Subject: [PATCH 09/12] Add more permission stuff --- care/security/controllers/facility.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/care/security/controllers/facility.py b/care/security/controllers/facility.py index 58b1a1a5dd..960661d346 100644 --- a/care/security/controllers/facility.py +++ b/care/security/controllers/facility.py @@ -1,3 +1,4 @@ +from care.abdm.utils.api_call import Facility from care.facility.models import FacilityUser from care.security.controllers.controller import ( AuthorizationHandler, @@ -7,6 +8,7 @@ class FacilityAccess(AuthorizationHandler): actions = ["can_read_facility"] + queries = ["allowed_facilities"] def can_read_facility(self, user, facility_id): self.check_permission(user, facility_id) @@ -15,3 +17,7 @@ def can_read_facility(self, user, facility_id): 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) From 75bfaca34119ed2b3f6aeaf8cf987c84d4d556bc Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Thu, 17 Oct 2024 13:07:49 +0530 Subject: [PATCH 10/12] Fix linting --- care/security/controllers/facility.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/care/security/controllers/facility.py b/care/security/controllers/facility.py index 960661d346..428c92ab74 100644 --- a/care/security/controllers/facility.py +++ b/care/security/controllers/facility.py @@ -18,6 +18,5 @@ def can_read_facility(self, user, facility_id): raise PermissionDeniedError return True, True - - def allowed_facilities(self , user): + def allowed_facilities(self, user): return Facility.objects.filter(users__id__exact=user.id) From 3dedcfe7ab479893834f2deeedeaa76a3e49fdc6 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Wed, 30 Oct 2024 15:14:39 +0530 Subject: [PATCH 11/12] Rename and restructure --- care/security/apps.py | 2 +- care/security/{controllers => authorization}/__init__.py | 0 .../{controllers/controller.py => authorization/base.py} | 2 +- care/security/{controllers => authorization}/facility.py | 2 +- care/security/management/commands/sync_permissions_roles.py | 2 +- care/security/permissions/{permissions.py => base.py} | 0 care/security/permissions/facility.py | 2 +- care/security/roles/role.py | 2 +- 8 files changed, 6 insertions(+), 6 deletions(-) rename care/security/{controllers => authorization}/__init__.py (100%) rename care/security/{controllers/controller.py => authorization/base.py} (97%) rename care/security/{controllers => authorization}/facility.py (93%) rename care/security/permissions/{permissions.py => base.py} (100%) diff --git a/care/security/apps.py b/care/security/apps.py index a7f3e9420c..9f29b9bd7a 100644 --- a/care/security/apps.py +++ b/care/security/apps.py @@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _ -class FacilityConfig(AppConfig): +class SecurityConfig(AppConfig): name = "care.security" verbose_name = _("Security Management") diff --git a/care/security/controllers/__init__.py b/care/security/authorization/__init__.py similarity index 100% rename from care/security/controllers/__init__.py rename to care/security/authorization/__init__.py diff --git a/care/security/controllers/controller.py b/care/security/authorization/base.py similarity index 97% rename from care/security/controllers/controller.py rename to care/security/authorization/base.py index bd1af1013b..36db9c022e 100644 --- a/care/security/controllers/controller.py +++ b/care/security/authorization/base.py @@ -1,4 +1,4 @@ -from care.security.permissions.permissions import PermissionController +from care.security.permissions.base import PermissionController class PermissionDeniedError(Exception): diff --git a/care/security/controllers/facility.py b/care/security/authorization/facility.py similarity index 93% rename from care/security/controllers/facility.py rename to care/security/authorization/facility.py index 428c92ab74..11de91f2b7 100644 --- a/care/security/controllers/facility.py +++ b/care/security/authorization/facility.py @@ -1,6 +1,6 @@ from care.abdm.utils.api_call import Facility from care.facility.models import FacilityUser -from care.security.controllers.controller import ( +from care.security.authorization.base import ( AuthorizationHandler, PermissionDeniedError, ) diff --git a/care/security/management/commands/sync_permissions_roles.py b/care/security/management/commands/sync_permissions_roles.py index c8e72cfe26..5d5808e7e7 100644 --- a/care/security/management/commands/sync_permissions_roles.py +++ b/care/security/management/commands/sync_permissions_roles.py @@ -2,7 +2,7 @@ from django.db import transaction from care.security.models import PermissionModel, RoleModel, RolePermission -from care.security.permissions.permissions import PermissionController +from care.security.permissions.base import PermissionController from care.security.roles.role import RoleController from care.utils.lock import Lock diff --git a/care/security/permissions/permissions.py b/care/security/permissions/base.py similarity index 100% rename from care/security/permissions/permissions.py rename to care/security/permissions/base.py diff --git a/care/security/permissions/facility.py b/care/security/permissions/facility.py index b39c116c83..ee1cafbd0e 100644 --- a/care/security/permissions/facility.py +++ b/care/security/permissions/facility.py @@ -1,6 +1,6 @@ import enum -from care.security.permissions.permissions import Permission, PermissionContext +from care.security.permissions.base import Permission, PermissionContext from care.security.roles.role import DOCTOR_ROLE, STAFF_ROLE diff --git a/care/security/roles/role.py b/care/security/roles/role.py index 40b9e81d34..dac2d8455e 100644 --- a/care/security/roles/role.py +++ b/care/security/roles/role.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from care.security.permissions.permissions import PermissionContext +from care.security.permissions.base import PermissionContext @dataclass From 62e68dd40c5801d7ba0499ccf2b31febc8874f08 Mon Sep 17 00:00:00 2001 From: vigneshhari Date: Wed, 30 Oct 2024 15:32:12 +0530 Subject: [PATCH 12/12] Use constraints in Django --- care/security/migrations/0001_initial.py | 4 ++-- care/security/models/role.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/care/security/migrations/0001_initial.py b/care/security/migrations/0001_initial.py index 524cabbd4c..37363e6084 100644 --- a/care/security/migrations/0001_initial.py +++ b/care/security/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.1 on 2024-10-01 17:03 +# Generated by Django 5.1.2 on 2024-10-30 10:00 import django.db.models.deletion import uuid @@ -48,7 +48,7 @@ class Migration(migrations.Migration): ('temp_deleted', models.BooleanField(default=False)), ], options={ - 'unique_together': {('name', 'context')}, + 'constraints': [models.UniqueConstraint(fields=('name', 'context'), name='unique_order')], }, ), migrations.CreateModel( diff --git a/care/security/models/role.py b/care/security/models/role.py index cae560bd5f..471aa44d35 100644 --- a/care/security/models/role.py +++ b/care/security/models/role.py @@ -1,4 +1,5 @@ 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 @@ -25,7 +26,9 @@ class RoleModel(BaseModel): temp_deleted = models.BooleanField(default=False) class Meta: - unique_together = ("name", "context") + constraints = [ + UniqueConstraint(name="unique_order", fields=["name", "context"]) + ] class RolePermission(BaseModel):