-
Notifications
You must be signed in to change notification settings - Fork 28
Feature/add user business access clean #432
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
Merged
Merged
Changes from 48 commits
Commits
Show all changes
49 commits
Select commit
Hold shift + click to select a range
c73105d
chore(squash): develop at fa3032c Merge pull request #419 from Y-Note…
zikani03 cc1abad
refactor(core): reorganize business models by adding UserBusinessAcce…
delcroip 803fde5
feat(core): alter UUID fields and create HistoricalUserBusinessAccess…
delcroip 671ac78
refactor(core): update user permissions handling and migrations
delcroip a89d226
feat(core): add validity_to and validity_from properties to ValidityM…
delcroip ea8d378
Update core/models/openimis_model.py
delcroip c1d3aeb
Apply suggestion from @Copilot
delcroip d01bb02
fix(core): correct field names in create_test_technical_user to match…
delcroip ca8ce12
refactor(user): extract auto-provisioning logic and update user creat…
delcroip deffb2b
feat(user): enhance permission system with business-level access control
delcroip a52fd86
feat: add command to generate and sync GQL permissions map
delcroip b3d7a9a
feat(core): add is_superuser field to UserSerializer
delcroip df66a1b
refactor(core): update generate_permissions_map command for relative …
delcroip 09a921d
refactor: enhance user creation and GraphQL test utilities
delcroip c893d91
fix: add app_label='core' to user models for proper Django app associ…
delcroip bf537a4
refactor(core): update _instance_to_gql_input to support field mapping
delcroip f9f3dad
fix(userServices): safely get roles with .get() default
delcroip 80da6ef
refactor(core): reorganize business models by adding UserBusinessAcce…
delcroip ba6beec
feat(core): alter UUID fields and create HistoricalUserBusinessAccess…
delcroip 61bc6f4
WIP: need to check auth: zoo many user request , 3x auth
delcroip 3089872
feat(mutations): add `mutation_on_queryset_from_filter` decorator
delcroip eae024c
fix(test_helpers): use silent save when updating interactive user props
delcroip 10e0ac7
feat(core): add link_type field to UserBusinessAccess model
delcroip ad6c50c
feat(admin): add link_type to UserBusinessAccessAdmin display and fil…
delcroip 6c08562
refactor(core): extract UserTypeEnum to dedicated module and add user…
delcroip 0787bf9
style: fix whitespace, trailing spaces, and unused imports
delcroip 86ea8d2
feat(core): add custom createsuperuser management command
delcroip 2b7ef77
fix(core): grant IMIS admin access to superusers and guard against nu…
delcroip 25181b8
refactor(core): extract auth logic and add check_permissions decorator
delcroip ca67d79
fix(auth): call Django login() for staff users to persist session
delcroip 666fdb2
feat: use std django createsureuser approach
delcroip a32eeb8
fix(core): grant IMIS admin full access and fix related edge cases
delcroip bdc2b83
style: remove extra blank line in CoreConfig class
delcroip 475339b
fix: import missing, reverting change
delcroip e12ebee
fix(core): set is_superuser for admin users via data migration
delcroip 1402ef8
fix: wrapping boolean logic in ORM filter with a Q
delcroip 84ba219
chore: rework migrations
delcroip 391d8cd
chore: flake8
delcroip 61ff44b
refactor(core): optimize superuser migration to use single query
delcroip c806ffb
fix: remove redundant validity_to filter in admin superuser migration
delcroip 368926f
core: cherrypick https://github.com/openimis/openimis-be-core_py/pull…
delcroip bee84eb
refactor: remove unused JWT middleware and fix Permissions-Policy syntax
delcroip 110bd83
feat(core): extend code column widening to ClaimAdmin and add tests
delcroip fe1dbc9
refactor: implement DB-level UUIDv7 generation and fix filter_validit…
delcroip ec93c48
chore: try to fix merge artifact breaking flake8
delcroip 943c4c9
fix(core): correct import of db_connection from django.db
delcroip f95437f
style(core): remove unnecessary blank line in gql_queries.py
delcroip b34fa58
chore: fix test with database having lots of eo
delcroip dbec166
fix: improve error handling for permissions and role lookup
delcroip File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,302 @@ | ||
| import logging | ||
| from datetime import datetime | ||
|
|
||
| from django.contrib.auth.models import AnonymousUser | ||
| from django.contrib.contenttypes.models import ContentType | ||
| from django.db.models import Q | ||
| from django.http import JsonResponse | ||
|
|
||
| from core.utils import ( | ||
| get_business_access_cache, | ||
| get_content_type_cache, | ||
| get_current_user, | ||
| is_authentication_checked, | ||
| set_authentication_checked, | ||
| ) | ||
| from core.models.user_business_access import UserBusinessAccess | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| SERVICE_AUTH_ERROR = { | ||
| "success": False, | ||
| "message": "Authentication required", | ||
| "detail": "PermissionDenied", | ||
| } | ||
|
|
||
| SERVICE_PERMISSION_ERROR = { | ||
| "success": False, | ||
| "message": "Permissions required", | ||
| "detail": "PermissionDenied", | ||
| } | ||
|
|
||
| # Shared format for business access checks: | ||
| # [content_type_label, object_id] | ||
| # [content_type_label, object_id, [perm_ids...]] | ||
| ACCESS_REQUIREMENT_FORMAT = ( | ||
| "List of [content_type_label, object_id] or " | ||
| "[content_type_label, object_id, [perm_ids...]] entries, " | ||
| "where content_type_label is 'app_label.modelname'." | ||
| ) | ||
|
|
||
|
|
||
| def is_core_user(obj): | ||
| """Detect openIMIS core.User without importing User (avoids circular imports).""" | ||
| if obj is None or isinstance(obj, AnonymousUser): | ||
| return False | ||
|
|
||
| meta = getattr(obj, "_meta", None) | ||
| if meta is not None: | ||
| return getattr(meta, "label_lower", None) == "core.user" | ||
|
|
||
| cls = type(obj) | ||
| return ( | ||
| cls.__name__ == "User" | ||
| and cls.__module__.endswith(".models.user") | ||
| and hasattr(obj, "has_perms") | ||
| ) | ||
|
|
||
|
|
||
| def is_authenticated_user(user): | ||
| return is_core_user(user) and getattr(user, "id", None) | ||
|
|
||
|
|
||
| def is_http_request(obj): | ||
| return hasattr(obj, "META") and hasattr(obj, "method") | ||
|
|
||
|
|
||
| def resolve_user(user_param, args, kwargs): | ||
| if is_core_user(user_param): | ||
| return user_param | ||
|
|
||
| resolved_user = get_current_user() | ||
| if resolved_user: | ||
| return resolved_user | ||
| if callable(user_param): | ||
| resolved = user_param(*args, **kwargs) | ||
| if resolved: | ||
| return resolved | ||
| elif user_param is not None and isinstance(user_param, str) and args: | ||
| resolved = getattr(args[0], user_param, None) | ||
| if resolved: | ||
| return resolved | ||
|
|
||
| if args and hasattr(args[0], "user") and not is_http_request(args[0]): | ||
| instance = args[0] | ||
| if instance.user: | ||
| return instance.user | ||
|
|
||
| return None | ||
|
|
||
|
|
||
| def parse_access_requirement(requirement): | ||
| if not requirement or len(requirement) < 2: | ||
| return None, None, None | ||
|
|
||
| content_type_label = requirement[0] | ||
| object_id = requirement[1] | ||
| perms = requirement[2] if len(requirement) > 2 else None | ||
| return content_type_label, object_id, perms | ||
|
|
||
|
|
||
| def _content_type_cache_key(content_type_label=None, *, app_label=None, business_object=None): | ||
| if business_object is not None: | ||
| return ("model", business_object.__class__.__module__, business_object.__class__.__name__) | ||
| if isinstance(content_type_label, ContentType): | ||
| return ("pk", content_type_label.pk) | ||
| if content_type_label is None: | ||
| return None | ||
|
|
||
| label = str(content_type_label) | ||
| if app_label: | ||
| return ("label", app_label, label.rsplit(".", 1)[-1].lower()) | ||
| if "." in label: | ||
| app_label, model_name = label.rsplit(".", 1) | ||
| return ("label", app_label, model_name.lower()) | ||
| return ("model_name", label.lower()) | ||
|
|
||
|
|
||
| def _business_access_cache_key(user, content_type, object_id): | ||
| user_id = getattr(user, "pk", None) or getattr(user, "id", None) | ||
| return (user_id, content_type.pk, str(object_id)) | ||
|
|
||
|
|
||
| def resolve_content_type(content_type_label=None, *, app_label=None, business_object=None): | ||
| if business_object is not None: | ||
| cache_key = _content_type_cache_key(business_object=business_object) | ||
| elif isinstance(content_type_label, ContentType): | ||
| return content_type_label | ||
| elif content_type_label is None: | ||
| return None | ||
| else: | ||
| cache_key = _content_type_cache_key(content_type_label, app_label=app_label) | ||
|
|
||
| if cache_key is not None: | ||
| content_type_cache = get_content_type_cache() | ||
| if cache_key in content_type_cache: | ||
| return content_type_cache[cache_key] | ||
|
|
||
| if business_object is not None: | ||
| content_type = ContentType.objects.get_for_model(business_object.__class__) | ||
| elif isinstance(content_type_label, ContentType): | ||
| content_type = content_type_label | ||
| else: | ||
| label = str(content_type_label) | ||
| if app_label: | ||
| model_name = label.rsplit(".", 1)[-1] | ||
| content_type = ContentType.objects.filter( | ||
| app_label=app_label, | ||
| model=model_name.lower(), | ||
| ).first() | ||
| elif "." in label: | ||
| app_label, model_name = label.rsplit(".", 1) | ||
| content_type = ContentType.objects.filter( | ||
| app_label=app_label, | ||
| model=model_name.lower(), | ||
| ).first() | ||
| else: | ||
| content_type = ContentType.objects.filter(model__iexact=label.lower()).first() | ||
|
|
||
| if cache_key is not None and content_type is not None: | ||
| content_type_cache[cache_key] = content_type | ||
| return content_type | ||
|
|
||
|
|
||
| def has_business_access(user, *, content_type=None, object_id=None, business_object=None, now=None): | ||
| if not user: | ||
| return False | ||
|
|
||
| if business_object is not None: | ||
| content_type = resolve_content_type(business_object=business_object) | ||
| object_id = str(business_object.pk) | ||
|
|
||
| if not content_type or object_id is None: | ||
| return False | ||
|
|
||
| cache_key = _business_access_cache_key(user, content_type, object_id) | ||
| business_access_cache = get_business_access_cache() | ||
| if cache_key in business_access_cache: | ||
| return business_access_cache[cache_key] | ||
|
|
||
| now = now or datetime.now() | ||
| has_access = UserBusinessAccess.objects.filter( | ||
| user=user, | ||
| content_type=content_type, | ||
| object_id=str(object_id), | ||
| active=True, | ||
| date_valid_from__lte=now, | ||
| ).filter( | ||
| Q(date_valid_to__isnull=True) | Q(date_valid_to__gte=now) | ||
| ).exists() | ||
| business_access_cache[cache_key] = has_access | ||
| return has_access | ||
|
|
||
|
|
||
| def has_role_perms(user, perm_list, *, list_evaluation_or=True): | ||
| if ( | ||
| getattr(user, "is_superuser", False) | ||
| or getattr(user, "is_imis_admin", False) | ||
| or not perm_list | ||
| ): | ||
| return True | ||
| if list_evaluation_or: | ||
| return any(user.has_perm(perm) for perm in perm_list) | ||
| return all(user.has_perm(perm) for perm in perm_list) | ||
|
|
||
|
|
||
| def satisfies_access_requirement(user, requirement, *, now=None): | ||
| content_type_label, object_id, perms = parse_access_requirement(requirement) | ||
| if not content_type_label: | ||
| return False | ||
|
|
||
| if perms and not has_role_perms(user, perms): | ||
| return False | ||
|
|
||
| app_label, model_name = content_type_label.rsplit(".", 1) | ||
| content_type = resolve_content_type(model_name, app_label=app_label) | ||
| if not content_type: | ||
| logger.error("Invalid content type: %s", content_type_label) | ||
| return False | ||
|
|
||
| return has_business_access( | ||
| user, | ||
| content_type=content_type, | ||
| object_id=object_id, | ||
| now=now, | ||
| ) | ||
|
|
||
|
|
||
| def evaluate_access_requirements(user, access_requirements, *, match_all=False, now=None): | ||
| if not access_requirements: | ||
| return True | ||
|
|
||
| now = now or datetime.now() | ||
| results = [ | ||
| satisfies_access_requirement(user, requirement, now=now) | ||
| for requirement in access_requirements | ||
| ] | ||
| return all(results) if match_all else any(results) | ||
|
|
||
|
|
||
| def user_has_permissions( | ||
| user, | ||
| permissions, | ||
| *, | ||
| access_requirements=None, | ||
| list_evaluation_or=True, | ||
| ): | ||
| if not is_authenticated_user(user): | ||
| return False | ||
| return user.has_perms( | ||
| permissions, | ||
| access_requirements=access_requirements, | ||
| list_evaluation_or=list_evaluation_or, | ||
| ) | ||
|
|
||
|
|
||
| def authentication_error(for_view=False): | ||
| if for_view: | ||
| return JsonResponse({"error": "Authentication required"}, status=401) | ||
| return SERVICE_AUTH_ERROR | ||
|
|
||
|
|
||
| def permission_error(for_view=False): | ||
| if for_view: | ||
| return JsonResponse({"error": "Forbidden"}, status=403) | ||
| return SERVICE_PERMISSION_ERROR | ||
|
|
||
|
|
||
| def guard_user_access( | ||
| *, | ||
| user_param, | ||
| args, | ||
| kwargs, | ||
| for_view=False, | ||
| require_auth=True, | ||
| permissions=None, | ||
| access_requirements=None, | ||
| list_evaluation_or=True, | ||
| ): | ||
| resolved_user = resolve_user(user_param, args, kwargs) | ||
|
|
||
| if require_auth and not is_authentication_checked(): | ||
| if not is_authenticated_user(resolved_user): | ||
| return authentication_error(for_view) | ||
| set_authentication_checked() | ||
|
|
||
| if permissions is not None: | ||
| if not user_has_permissions( | ||
| resolved_user, | ||
| permissions, | ||
| access_requirements=access_requirements, | ||
| list_evaluation_or=list_evaluation_or, | ||
| ): | ||
| return permission_error(for_view) | ||
| elif access_requirements: | ||
| if not evaluate_access_requirements( | ||
| resolved_user, | ||
| access_requirements, | ||
| match_all=True, | ||
| ): | ||
| return permission_error(for_view) | ||
|
|
||
| return None | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.