Skip to content
Merged
Show file tree
Hide file tree
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 May 21, 2026
cc1abad
refactor(core): reorganize business models by adding UserBusinessAcce…
delcroip Feb 20, 2026
803fde5
feat(core): alter UUID fields and create HistoricalUserBusinessAccess…
delcroip Feb 20, 2026
671ac78
refactor(core): update user permissions handling and migrations
delcroip Feb 20, 2026
a89d226
feat(core): add validity_to and validity_from properties to ValidityM…
delcroip Feb 24, 2026
ea8d378
Update core/models/openimis_model.py
delcroip Apr 9, 2026
c1d3aeb
Apply suggestion from @Copilot
delcroip Apr 9, 2026
d01bb02
fix(core): correct field names in create_test_technical_user to match…
delcroip Apr 9, 2026
ca8ce12
refactor(user): extract auto-provisioning logic and update user creat…
delcroip Apr 9, 2026
deffb2b
feat(user): enhance permission system with business-level access control
delcroip Apr 17, 2026
a52fd86
feat: add command to generate and sync GQL permissions map
delcroip Apr 17, 2026
b3d7a9a
feat(core): add is_superuser field to UserSerializer
delcroip Apr 23, 2026
df66a1b
refactor(core): update generate_permissions_map command for relative …
delcroip Apr 23, 2026
09a921d
refactor: enhance user creation and GraphQL test utilities
delcroip Apr 28, 2026
c893d91
fix: add app_label='core' to user models for proper Django app associ…
delcroip Apr 29, 2026
bf537a4
refactor(core): update _instance_to_gql_input to support field mapping
delcroip May 6, 2026
f9f3dad
fix(userServices): safely get roles with .get() default
delcroip May 6, 2026
80da6ef
refactor(core): reorganize business models by adding UserBusinessAcce…
delcroip Feb 20, 2026
ba6beec
feat(core): alter UUID fields and create HistoricalUserBusinessAccess…
delcroip Feb 20, 2026
61bc6f4
WIP: need to check auth: zoo many user request , 3x auth
delcroip May 8, 2026
3089872
feat(mutations): add `mutation_on_queryset_from_filter` decorator
delcroip Jun 9, 2026
eae024c
fix(test_helpers): use silent save when updating interactive user props
delcroip Jun 9, 2026
10e0ac7
feat(core): add link_type field to UserBusinessAccess model
delcroip Jun 9, 2026
ad6c50c
feat(admin): add link_type to UserBusinessAccessAdmin display and fil…
delcroip Jun 10, 2026
6c08562
refactor(core): extract UserTypeEnum to dedicated module and add user…
delcroip Jun 10, 2026
0787bf9
style: fix whitespace, trailing spaces, and unused imports
delcroip Jun 10, 2026
86ea8d2
feat(core): add custom createsuperuser management command
delcroip Jun 10, 2026
2b7ef77
fix(core): grant IMIS admin access to superusers and guard against nu…
delcroip Jun 10, 2026
25181b8
refactor(core): extract auth logic and add check_permissions decorator
delcroip Jun 10, 2026
ca67d79
fix(auth): call Django login() for staff users to persist session
delcroip Jun 10, 2026
666fdb2
feat: use std django createsureuser approach
delcroip Jun 10, 2026
a32eeb8
fix(core): grant IMIS admin full access and fix related edge cases
delcroip Jun 11, 2026
bdc2b83
style: remove extra blank line in CoreConfig class
delcroip Jun 11, 2026
475339b
fix: import missing, reverting change
delcroip Jun 22, 2026
e12ebee
fix(core): set is_superuser for admin users via data migration
delcroip Jun 25, 2026
1402ef8
fix: wrapping boolean logic in ORM filter with a Q
delcroip Jun 26, 2026
84ba219
chore: rework migrations
delcroip Jun 26, 2026
391d8cd
chore: flake8
delcroip Jun 26, 2026
61ff44b
refactor(core): optimize superuser migration to use single query
delcroip Jun 29, 2026
c806ffb
fix: remove redundant validity_to filter in admin superuser migration
delcroip Jun 29, 2026
368926f
core: cherrypick https://github.com/openimis/openimis-be-core_py/pull…
delcroip Jun 29, 2026
bee84eb
refactor: remove unused JWT middleware and fix Permissions-Policy syntax
delcroip Jun 30, 2026
110bd83
feat(core): extend code column widening to ClaimAdmin and add tests
delcroip Jul 1, 2026
fe1dbc9
refactor: implement DB-level UUIDv7 generation and fix filter_validit…
delcroip Jul 8, 2026
ec93c48
chore: try to fix merge artifact breaking flake8
delcroip Jul 8, 2026
943c4c9
fix(core): correct import of db_connection from django.db
delcroip Jul 8, 2026
f95437f
style(core): remove unnecessary blank line in gql_queries.py
delcroip Jul 8, 2026
b34fa58
chore: fix test with database having lots of eo
delcroip Jul 8, 2026
dbec166
fix: improve error handling for permissions and role lookup
delcroip Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 302 additions & 0 deletions core/access.py
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)
Comment thread
delcroip marked this conversation as resolved.
Outdated
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
10 changes: 9 additions & 1 deletion core/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.contrib import admin
from django.contrib.auth.models import Group, Permission
from .models import FieldControl, ModuleConfiguration, TechnicalUser
from .models import FieldControl, ModuleConfiguration, TechnicalUser, UserBusinessAccess
from .forms import TechnicalUserAdmin, GroupAdmin

admin.site.unregister(Group)
Expand All @@ -10,3 +10,11 @@
admin.site.register(TechnicalUser, TechnicalUserAdmin)
admin.site.register(Permission)
admin.site.register(Group, GroupAdmin)


@admin.register(UserBusinessAccess)
class UserBusinessAccessAdmin(admin.ModelAdmin):
list_display = ['user', 'link_type', 'content_type', 'object_id', 'date_valid_from', 'date_valid_to', 'active']
list_filter = ['link_type', 'content_type', 'active']
search_fields = ['user__username', 'object_id']
raw_id_fields = ['user', 'content_type']
1 change: 0 additions & 1 deletion core/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,6 @@ def _configure_additional_settings(self, cfg):

def ready(self):
from .models import ModuleConfiguration

cfg = ModuleConfiguration.get_or_default(MODULE_NAME, DEFAULT_CFG)
self._configure_calendar(cfg)
self._configure_user_config(cfg)
Expand Down
Loading
Loading