diff --git a/.gitignore b/.gitignore index d420b5b..2db7dcc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ media/ generate_folder_structure.py testing/ -docs/ \ No newline at end of file +docs/ +scripts/ diff --git a/aegis/admin.py b/aegis/admin.py index 4098c89..84c141a 100644 --- a/aegis/admin.py +++ b/aegis/admin.py @@ -2,6 +2,7 @@ from django import forms from django.contrib import admin, messages +from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.auth.admin import UserAdmin from django.contrib.admin.forms import AdminAuthenticationForm from django.contrib.auth.models import Group @@ -17,6 +18,7 @@ RegisteredService, BlacklistedAccess, BlacklistedRefresh, RequestLog, GroupServiceAccess, ServiceScopeAssignment, FarmCalendarResourceCache, ServiceRole) from .services.fc_catalog_sync import ensure_farmcalendar_catalog_fresh +from .services.entitlement_service import fc_service_queryset class HiddenGroupAdmin(admin.ModelAdmin): @@ -57,38 +59,53 @@ def field_choices(self, field, request, model_admin): class ServiceScopeAssignmentAdminForm(forms.ModelForm): - SUBJECT_TYPE_CHOICES = ( - ("user", "Individual user"), + PERMISSION_ACTION_CHOICES = ( + ("view", "view"), + ("add", "add"), + ("edit", "edit"), + ("delete", "delete"), ) role_ref = forms.ModelChoiceField( queryset=ServiceRole.objects.filter(status=1).select_related("service").order_by("service__service_code", "role_name"), required=False, - label="Role", - help_text="Choose the database-backed role. Actions are derived automatically from that role.", + label="Role (preset)", + help_text=( + "RBAC path: pick a predefined Service Role. Its permissions are used. " + "Leave blank if you want to assign permissions directly below." + ), ) - subject_type = forms.ChoiceField( - choices=SUBJECT_TYPE_CHOICES, - initial="user", - help_text="Assignments are user-based. Select the individual user who should receive this role.", + permissions_direct = forms.MultipleChoiceField( + choices=PERMISSION_ACTION_CHOICES, + required=False, + widget=forms.CheckboxSelectMultiple, + label="Permissions (direct)", + help_text=( + "OBAC path: tick the actions to grant directly. " + "Only used when Role is left blank." + ), ) subject_user = forms.ModelChoiceField( - queryset=DefaultAuthUserExtend.objects.filter(is_tenant_admin=False).order_by("email"), + queryset=DefaultAuthUserExtend.objects.filter( + is_tenant_admin=False, is_superuser=False + ).order_by("email"), required=True, label="Individual user", - help_text="Select the user who should receive this role in the current tenant.", + help_text="Select the user who should receive this grant in the selected tenant.", ) - scope_farm = forms.ModelChoiceField( + scope_farms = forms.ModelMultipleChoiceField( queryset=FarmCalendarResourceCache.objects.filter(resource_type="farm", status=1).order_by("name", "resource_id"), required=False, - label="Farm", - help_text="Select the farm to which this access applies.", + label="Farms", + widget=FilteredSelectMultiple("Farms", is_stacked=False), + help_text="Pick one or more farms. One grant row will be created per farm.", ) - scope_parcel = forms.ModelChoiceField( + scope_parcels = forms.ModelMultipleChoiceField( queryset=FarmCalendarResourceCache.objects.filter(resource_type="parcel", status=1).order_by("name", "resource_id"), required=False, - label="Parcel", - help_text="Select the parcel to which this access applies.", + label="Parcels", + widget=FilteredSelectMultiple("Parcels", is_stacked=False), + help_text="Pick one or more parcels. One grant row will be created per parcel.", ) class Meta: @@ -97,6 +114,7 @@ class Meta: def __init__(self, *args, **kwargs): request = kwargs.pop("request", None) + self._request = request super().__init__(*args, **kwargs) self.fields["user"].widget = forms.HiddenInput() self.fields["scope_id"].widget = forms.HiddenInput() @@ -105,80 +123,138 @@ def __init__(self, *args, **kwargs): self.fields["user"].required = False self.fields["actions"].required = False self.fields["role"].required = False - self.fields["scope_type"].help_text = "Choose whether this scope applies to a farm or to a parcel." + self.fields["scope_type"].help_text = "Choose 'farm' or 'parcel'. Required for Farm Calendar." + self.fields["scope_type"].required = False self.fields["scope_id"].required = False + fc_services = fc_service_queryset(ServiceMaster.objects.filter(status=1)) if request is not None and not request.user.is_superuser and getattr(request.user, "tenant_id", None): + tenant_id = request.user.tenant_id self.fields["role_ref"].queryset = ServiceRole.objects.filter( status=1, - tenant_id=request.user.tenant_id, + tenant_id=tenant_id, + service__in=fc_services, ).select_related("service", "tenant").order_by("service__service_code", "role_name") + self.fields["subject_user"].queryset = DefaultAuthUserExtend.objects.filter( + tenant_id=tenant_id, + is_tenant_admin=False, + is_superuser=False, + status=1, + ).order_by("email") + self.fields["scope_farms"].queryset = FarmCalendarResourceCache.objects.filter( + tenant_id=tenant_id, resource_type="farm", status=1, + ).order_by("name", "resource_id") + self.fields["scope_parcels"].queryset = FarmCalendarResourceCache.objects.filter( + tenant_id=tenant_id, resource_type="parcel", status=1, + ).order_by("name", "resource_id") else: - self.fields["role_ref"].queryset = ServiceRole.objects.filter(status=1).select_related( - "service", "tenant" - ).order_by("tenant__code", "service__service_code", "role_name") + self.fields["role_ref"].queryset = ServiceRole.objects.filter( + status=1, + service__in=fc_services, + ).select_related("service", "tenant").order_by("tenant__code", "service__service_code", "role_name") self.fields["role_ref"].label_from_instance = lambda obj: ( f"{obj.role_name} - {obj.service.service_code}" + (f" [{obj.tenant.code}]" if obj.tenant_id else "") ) - self.fields["scope_farm"].label_from_instance = lambda obj: f"{obj.name or '-'} - {obj.resource_id}" - self.fields["scope_parcel"].label_from_instance = lambda obj: f"{obj.name or '-'} - {obj.resource_id}" + self.fields["scope_farms"].label_from_instance = lambda obj: f"{obj.name or '-'} - {obj.resource_id}" + self.fields["scope_parcels"].label_from_instance = lambda obj: f"{obj.name or '-'} - {obj.resource_id}" if self.instance and self.instance.pk: if self.instance.user_id: - self.fields["subject_type"].initial = "user" self.fields["subject_user"].initial = self.instance.user if self.instance.role_ref_id: self.fields["role_ref"].initial = self.instance.role_ref + elif isinstance(self.instance.actions, list) and self.instance.actions: + # Only pre-fill direct permissions for OBAC rows (no role_ref). + # RBAC rows also carry actions[] (copied from the role at save + # time), but pre-filling both fields would trigger the XOR + # validator on Save with no field changes. + self.fields["permissions_direct"].initial = self.instance.actions if self.instance.scope_type == "farm": - self.fields["scope_farm"].initial = FarmCalendarResourceCache.objects.filter( + existing = FarmCalendarResourceCache.objects.filter( resource_type="farm", resource_id=self.instance.scope_id, ).first() + if existing: + self.fields["scope_farms"].initial = [existing] elif self.instance.scope_type == "parcel": - self.fields["scope_parcel"].initial = FarmCalendarResourceCache.objects.filter( + existing = FarmCalendarResourceCache.objects.filter( resource_type="parcel", resource_id=self.instance.scope_id, ).first() + if existing: + self.fields["scope_parcels"].initial = [existing] def clean(self): cleaned_data = super().clean() - subject_type = cleaned_data.get("subject_type") subject_user = cleaned_data.get("subject_user") scope_type = cleaned_data.get("scope_type") - scope_farm = cleaned_data.get("scope_farm") - scope_parcel = cleaned_data.get("scope_parcel") + scope_farms = list(cleaned_data.get("scope_farms") or []) + scope_parcels = list(cleaned_data.get("scope_parcels") or []) role_ref = cleaned_data.get("role_ref") + permissions_direct = list(cleaned_data.get("permissions_direct") or []) service = cleaned_data.get("service") + tenant = cleaned_data.get("tenant") - if subject_type != "user": - raise forms.ValidationError("Service scope assignments must target an individual user.") if not subject_user: raise forms.ValidationError("Select an individual user for this assignment.") + if tenant and getattr(subject_user, "tenant_id", None) != tenant.id: + raise forms.ValidationError( + "Selected user does not belong to the selected tenant." + ) cleaned_data["user"] = subject_user cleaned_data["group"] = None - if scope_type == "farm": - if not scope_farm: - raise forms.ValidationError("Select a farm when scope type is 'farm'.") - cleaned_data["scope_id"] = scope_farm.resource_id - elif scope_type == "parcel": - if not scope_parcel: - raise forms.ValidationError("Select a parcel when scope type is 'parcel'.") - cleaned_data["scope_id"] = scope_parcel.resource_id - else: - raise forms.ValidationError("Choose whether this scope applies to a farm or a parcel.") - - if not role_ref: - raise forms.ValidationError("Select a role for this assignment.") - if service and role_ref.service_id != service.id: - raise forms.ValidationError("Selected role does not belong to the selected service.") + # RBAC XOR OBAC — exactly one path must be chosen. + if role_ref and permissions_direct: + raise forms.ValidationError( + "Choose either a Role OR direct Permissions, not both." + ) + if not role_ref and not permissions_direct: + raise forms.ValidationError( + "Choose a Role OR tick at least one direct Permission." + ) - cleaned_data["role"] = role_ref.role_code - cleaned_data["actions"] = list( - role_ref.permissions.filter(status=1).order_by("action").values_list("action", flat=True) - ) + if role_ref: + if service and role_ref.service_id != service.id: + raise forms.ValidationError( + "Selected role does not belong to the selected service." + ) + cleaned_data["role"] = role_ref.role_code + cleaned_data["actions"] = list( + role_ref.permissions.filter(status=1).order_by("action").values_list("action", flat=True) + ) + else: + cleaned_data["role"] = "" + cleaned_data["actions"] = sorted(set(permissions_direct)) + + service_for_scope = service or getattr(role_ref, "service", None) + is_fc = False + if service_for_scope is not None: + code = (service_for_scope.service_code or "").strip().lower() + name = (service_for_scope.service_name or "").strip().lower() + is_fc = code in {"fc", "farmcalendar"} or name in {"farmcalendar", "farm calendar"} + + if is_fc: + if scope_type == "farm": + if not scope_farms: + raise forms.ValidationError("Select at least one farm.") + cleaned_data["_selected_scopes"] = scope_farms + cleaned_data["scope_id"] = scope_farms[0].resource_id + elif scope_type == "parcel": + if not scope_parcels: + raise forms.ValidationError("Select at least one parcel.") + cleaned_data["_selected_scopes"] = scope_parcels + cleaned_data["scope_id"] = scope_parcels[0].resource_id + else: + raise forms.ValidationError( + "Farm Calendar grants require a scope (farm or parcel)." + ) + else: + cleaned_data["scope_type"] = None + cleaned_data["scope_id"] = None + cleaned_data["_selected_scopes"] = [] return cleaned_data @@ -403,6 +479,18 @@ class TenantAdmin(SuperuserOnlyAdminMixin, HideDeletedByDefaultMixin, StatusBadg actions = ("mark_active", "mark_inactive", "soft_delete_selected", "restore_selected", "export_as_csv") EXPORT_FIELDS = ("code", "slug", "name", "status", "created_at", "updated_at", "deleted_at") + @admin.action(description="Soft delete selected") + def soft_delete_selected(self, request, queryset): + updated = 0 + for obj in queryset.exclude(status=2): + obj.soft_delete() + updated += 1 + self.message_user( + request, + f"{updated} tenant record(s) soft-deleted and identifiers released.", + level=messages.WARNING, + ) + def get_actions(self, request): actions = super().get_actions(request) actions.pop("delete_selected", None) @@ -514,10 +602,13 @@ def get_form(self, request, obj=None, change=False, **kwargs): if "email" in form.base_fields: form.base_fields["email"].required = True form.base_fields["email"].help_text = "Required. Must be unique." - if not request.user.is_superuser and "tenant" in form.base_fields: - form.base_fields["tenant"].queryset = Tenant.objects.filter(id=request.user.tenant_id) - form.base_fields["tenant"].initial = request.user.tenant_id - form.base_fields["tenant"].disabled = True + if "tenant" in form.base_fields: + if request.user.is_superuser: + form.base_fields["tenant"].queryset = Tenant.objects.filter(status=1).order_by("code") + else: + form.base_fields["tenant"].queryset = Tenant.objects.filter(id=request.user.tenant_id, status=1) + form.base_fields["tenant"].initial = request.user.tenant_id + form.base_fields["tenant"].disabled = True if not request.user.is_superuser and "is_superuser" in form.base_fields: form.base_fields["is_superuser"].disabled = True if not request.user.is_superuser and "is_staff" in form.base_fields: @@ -578,6 +669,18 @@ class ServiceMasterAdmin(SuperuserOnlyAdminMixin, HideDeletedByDefaultMixin, Sta actions = ("mark_active", "mark_inactive", "soft_delete_selected", "restore_selected", "export_as_csv") EXPORT_FIELDS = ("service_code", "service_name", "service_description", "status", "created_at", "updated_at", "deleted_at") + @admin.action(description="Soft delete selected") + def soft_delete_selected(self, request, queryset): + updated = 0 + for obj in queryset.exclude(status=2): + obj.soft_delete() + updated += 1 + self.message_user( + request, + f"{updated} service record(s) soft-deleted and identifiers released.", + level=messages.WARNING, + ) + # Remove hard delete def get_actions(self, request): actions = super().get_actions(request) @@ -802,11 +905,11 @@ def get_actions(self, request): @admin.register(ServiceRole) -class ServiceRoleAdmin(SuperuserOnlyAdminMixin, HideDeletedByDefaultMixin, StatusBadgeMixin, SoftDeleteActions, ActivateDeactivateActions, CSVExportMixin, admin.ModelAdmin): +class ServiceRoleAdmin(TenantScopedAdminMixin, HideDeletedByDefaultMixin, StatusBadgeMixin, SoftDeleteActions, ActivateDeactivateActions, CSVExportMixin, admin.ModelAdmin): list_display = ("tenant_display", "role_name", "role_code", "service_code", "permission_list", "status_badge", "updated_at") list_filter = ("status", ("tenant", TenantCodeListFilter), "service", "updated_at", "created_at") search_fields = ("role_name", "role_code", "tenant__code", "tenant__name", "service__service_code", "service__service_name", "description") - autocomplete_fields = ("service",) + autocomplete_fields = () filter_horizontal = ("permissions",) ordering = ("tenant__code", "service__service_code", "role_name") list_per_page = 50 @@ -846,7 +949,14 @@ def get_form(self, request, obj=None, change=False, **kwargs): form.base_fields["tenant"].initial = request.user.tenant_id form.base_fields["tenant"].disabled = True if "service" in form.base_fields: - form.base_fields["service"].queryset = ServiceMaster.objects.filter(status=1).order_by("service_code") + form.base_fields["service"].queryset = fc_service_queryset( + ServiceMaster.objects.filter(status=1) + ).order_by("service_code") + if "permissions" in form.base_fields: + form.base_fields["permissions"].queryset = PermissionMaster.objects.filter( + status=1, + service__in=fc_service_queryset(ServiceMaster.objects.filter(status=1)), + ).select_related("service").order_by("service__service_code", "action") return form def get_autocomplete_fields(self, request): @@ -859,11 +969,21 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs): if not request.user.is_superuser and db_field.name == "tenant": kwargs["queryset"] = Tenant.objects.filter(id=request.user.tenant_id) elif db_field.name == "service": - kwargs["queryset"] = ServiceMaster.objects.filter(status=1).order_by("service_code") + kwargs["queryset"] = fc_service_queryset( + ServiceMaster.objects.filter(status=1) + ).order_by("service_code") elif queryset is not None: kwargs["queryset"] = queryset return super().formfield_for_foreignkey(db_field, request, **kwargs) + def formfield_for_manytomany(self, db_field, request, **kwargs): + if db_field.name == "permissions": + kwargs["queryset"] = PermissionMaster.objects.filter( + status=1, + service__in=fc_service_queryset(ServiceMaster.objects.filter(status=1)), + ).select_related("service").order_by("service__service_code", "action") + return super().formfield_for_manytomany(db_field, request, **kwargs) + def save_model(self, request, obj, form, change): if not request.user.is_superuser: obj.tenant = request.user.tenant @@ -902,7 +1022,7 @@ class ServiceScopeAssignmentAdmin(TenantScopedAdminMixin, HideDeletedByDefaultMi list_display = ("tenant_display", "subject_display", "service_code", "role_display", "scope_type", "scope_display", "actions_display", "status_badge", "updated_at") list_filter = ("status", ("tenant", TenantCodeListFilter), "service", "scope_type", "role_ref", "updated_at", "created_at") search_fields = ("service__service_code", "service__service_name", "user__username", "user__email", "scope_id") - autocomplete_fields = ("service",) + autocomplete_fields = () ordering = ("service__service_code", "scope_type", "scope_id") list_per_page = 50 date_hierarchy = "created_at" @@ -920,19 +1040,30 @@ class ServiceScopeAssignmentAdmin(TenantScopedAdminMixin, HideDeletedByDefaultMi "status", "tenant", "service", - "subject_type", "subject_user", "user", - "role_ref", - "role", - "actions", "scope_type", - "scope_farm", - "scope_parcel", + "scope_farms", + "scope_parcels", "scope_id", ) }, ), + ( + "Permissions", + { + "description": ( + "Choose ONE of the following: either a preset Role " + "(RBAC) or tick permissions directly (OBAC)." + ), + "fields": ( + "role_ref", + "permissions_direct", + "role", + "actions", + ), + }, + ), ( "Lifecycle", { @@ -1011,10 +1142,14 @@ def __init__(self, *args, **inner_kwargs): self._disable_related_widget_controls(form, "tenant") if request.user.is_superuser: if "service" in form.base_fields: - form.base_fields["service"].queryset = ServiceMaster.objects.filter(status=1).order_by("service_code") + form.base_fields["service"].queryset = fc_service_queryset( + ServiceMaster.objects.filter(status=1) + ).order_by("service_code") return form if "service" in form.base_fields: - form.base_fields["service"].queryset = ServiceMaster.objects.filter(status=1).order_by("service_code") + form.base_fields["service"].queryset = fc_service_queryset( + ServiceMaster.objects.filter(status=1) + ).order_by("service_code") if "tenant" in form.base_fields: form.base_fields["tenant"].queryset = Tenant.objects.filter(id=request.user.tenant_id) form.base_fields["tenant"].initial = request.user.tenant_id @@ -1028,19 +1163,27 @@ def __init__(self, *args, **inner_kwargs): form.base_fields["role_ref"].queryset = ServiceRole.objects.filter( status=1, tenant_id=request.user.tenant_id, + service__in=fc_service_queryset(ServiceMaster.objects.filter(status=1)), ).select_related("service", "tenant").order_by("service__service_code", "role_name") - if "scope_farm" in form.base_fields: - form.base_fields["scope_farm"].queryset = FarmCalendarResourceCache.objects.filter( + if "scope_farms" in form.base_fields: + form.base_fields["scope_farms"].queryset = FarmCalendarResourceCache.objects.filter( tenant_id=request.user.tenant_id, resource_type="farm", status=1, ).order_by("name", "resource_id") - if "scope_parcel" in form.base_fields: - form.base_fields["scope_parcel"].queryset = FarmCalendarResourceCache.objects.filter( + if "scope_parcels" in form.base_fields: + form.base_fields["scope_parcels"].queryset = FarmCalendarResourceCache.objects.filter( tenant_id=request.user.tenant_id, resource_type="parcel", status=1, ).order_by("name", "resource_id") + if "subject_user" in form.base_fields: + form.base_fields["subject_user"].queryset = DefaultAuthUserExtend.objects.filter( + tenant_id=request.user.tenant_id, + is_tenant_admin=False, + is_superuser=False, + status=1, + ).order_by("email") return form def get_autocomplete_fields(self, request): @@ -1050,7 +1193,9 @@ def get_autocomplete_fields(self, request): def formfield_for_foreignkey(self, db_field, request, **kwargs): if db_field.name == "service": - kwargs["queryset"] = ServiceMaster.objects.filter(status=1).order_by("service_code") + kwargs["queryset"] = fc_service_queryset( + ServiceMaster.objects.filter(status=1) + ).order_by("service_code") return super().formfield_for_foreignkey(db_field, request, **kwargs) def save_model(self, request, obj, form, change): @@ -1058,7 +1203,40 @@ def save_model(self, request, obj, form, change): obj.tenant = request.user.tenant if obj.user_id and getattr(obj.user, "tenant_id", None) != request.user.tenant_id: raise forms.ValidationError("You can only assign roles to users in your tenant.") - super().save_model(request, obj, form, change) + + if change: + super().save_model(request, obj, form, change) + return + + selected_scopes = list(form.cleaned_data.get("_selected_scopes") or []) + scope_type = form.cleaned_data.get("scope_type") + if not selected_scopes: + super().save_model(request, obj, form, change) + return + + first = selected_scopes[0] + obj.scope_type = scope_type + obj.scope_id = first.resource_id + obj.save() + + for extra in selected_scopes[1:]: + ServiceScopeAssignment.objects.create( + tenant=obj.tenant, + service=obj.service, + user=obj.user, + role_ref=obj.role_ref, + role=obj.role, + actions=obj.actions, + scope_type=scope_type, + scope_id=extra.resource_id, + status=obj.status, + ) + if len(selected_scopes) > 1: + self.message_user( + request, + f"Created {len(selected_scopes)} grant rows ({scope_type} × user).", + level=messages.SUCCESS, + ) def get_actions(self, request): actions = super().get_actions(request) diff --git a/aegis/migrations/0015_servicescopeassignment_optional_scope.py b/aegis/migrations/0015_servicescopeassignment_optional_scope.py new file mode 100644 index 0000000..97e84ec --- /dev/null +++ b/aegis/migrations/0015_servicescopeassignment_optional_scope.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('aegis', '0014_alter_servicerole_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='servicescopeassignment', + name='scope_type', + field=models.CharField( + blank=True, + null=True, + max_length=20, + choices=[('farm', 'farm'), ('parcel', 'parcel')], + ), + ), + migrations.AlterField( + model_name='servicescopeassignment', + name='scope_id', + field=models.UUIDField(blank=True, null=True, db_index=True), + ), + ] diff --git a/aegis/models.py b/aegis/models.py index 895a350..3aaa7c0 100644 --- a/aegis/models.py +++ b/aegis/models.py @@ -62,6 +62,20 @@ class Meta: def __str__(self): return f"{self.code} - {self.name}" + def soft_delete(self): + if self.status == 2 and str(self.code).startswith("deleted__"): + return + prefix = f"deleted__{self.id.hex[:12]}__" + code_budget = max(1, 32 - len(prefix)) + slug_budget = max(1, 64 - len(prefix)) + name_budget = max(1, 255 - len(prefix)) + self.code = f"{prefix}{(self.code or '')[:code_budget]}" + self.slug = f"{prefix}{(self.slug or '')[:slug_budget]}" + self.name = f"{prefix}{(self.name or '')[:name_budget]}" + self.status = 2 + self.deleted_at = timezone.now() + self.save() + class RequestLog(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) @@ -327,6 +341,19 @@ class Meta: def __str__(self): return f"{self.service_name} ({self.service_code})" + def soft_delete(self): + if self.status == 2 and str(self.service_code).startswith("deleted__"): + return + code_prefix = f"deleted__{self.pk}__" + name_prefix = f"deleted__{self.pk}__" + code_budget = max(1, 50 - len(code_prefix)) + name_budget = max(1, 100 - len(name_prefix)) + self.service_code = f"{code_prefix}{(self.service_code or '')[:code_budget]}" + self.service_name = f"{name_prefix}{(self.service_name or '')[:name_budget]}" + self.status = 2 + self.deleted_at = timezone.now() + self.save() + class GroupServiceAccess(BaseModel): """ @@ -409,8 +436,8 @@ class ServiceScopeAssignment(BaseModel): ) role = models.CharField(max_length=50, default="viewer") actions = models.JSONField(default=list, blank=True) - scope_type = models.CharField(max_length=20, choices=SCOPE_TYPE_CHOICES) - scope_id = models.UUIDField(db_index=True) + scope_type = models.CharField(max_length=20, choices=SCOPE_TYPE_CHOICES, blank=True, null=True) + scope_id = models.UUIDField(db_index=True, blank=True, null=True) class Meta: db_table = "service_scope_assignments" diff --git a/aegis/services/entitlement_service.py b/aegis/services/entitlement_service.py index 5fa582a..4b4f30d 100644 --- a/aegis/services/entitlement_service.py +++ b/aegis/services/entitlement_service.py @@ -3,9 +3,7 @@ from django.db.models import Q from aegis.models import ( - CustomPermissions, FarmCalendarResourceCache, - GroupCustomPermissions, PermissionMaster, ServiceMaster, ServiceScopeAssignment, @@ -27,6 +25,18 @@ def _is_fc_service(service_code, service_name): ) +def fc_service_queryset(base_qs=None): + """Filter a ServiceMaster queryset (default: all rows) down to FC.""" + if base_qs is None: + base_qs = ServiceMaster.objects.all() + return base_qs.filter( + Q(service_code__iexact="FC") + | Q(service_code__iexact="farmcalendar") + | Q(service_name__iexact="Farm Calendar") + | Q(service_name__iexact="FarmCalendar") + ) + + def _empty_service_entry(service_code, service_name): return { "code": service_code, @@ -105,18 +115,30 @@ def _apply_superuser_entitlements(): return services -def _apply_tenant_admin_fc_entitlements(user, services): +def _apply_tenant_user_entitlements(user, services): + """Policy: + - Every user that belongs to a tenant gets all non-FC services tenant-wide + (unrestricted, all actions). + - Tenant admins additionally get FC enumerated to every farm/parcel in + their tenant. Non-admin users get FC only via explicit Role Grants. + """ tenant_id = getattr(user, "tenant_id", None) - if not getattr(user, "is_tenant_admin", False) or not tenant_id: + if not tenant_id: + return services + if getattr(user, "is_superuser", False): return services - active_fc_services = ServiceMaster.objects.filter(status=1).values_list("service_code", "service_name") - for service_code, service_name in active_fc_services: - if not _is_fc_service(service_code, service_name): + is_tenant_admin = bool(getattr(user, "is_tenant_admin", False)) + + active_services = ServiceMaster.objects.filter(status=1).values_list("service_code", "service_name") + for service_code, service_name in active_services: + is_fc = _is_fc_service(service_code, service_name) + if is_fc and not is_tenant_admin: continue entry = _ensure_service_entry(services, service_code, service_name) - entry["roles"].add("tenant_admin") + if is_tenant_admin: + entry["roles"].add("tenant_admin") permission_actions = PermissionMaster.objects.filter( status=1, @@ -127,20 +149,23 @@ def _apply_tenant_admin_fc_entitlements(user, services): if action: entry["actions"].add(action) - fc_rows = FarmCalendarResourceCache.objects.filter( - status=1, - tenant_id=tenant_id, - resource_type__in=("farm", "parcel"), - ).values_list("resource_type", "resource_id") - for scope_type, scope_id in fc_rows: - _append_assignment( - entry, - role="tenant_admin", - actions=sorted(entry["actions"]), - scope_type=scope_type, - scope_id=scope_id, - source="tenant_admin", - ) + if is_fc: + fc_rows = FarmCalendarResourceCache.objects.filter( + status=1, + tenant_id=tenant_id, + resource_type__in=("farm", "parcel"), + ).values_list("resource_type", "resource_id") + for scope_type, scope_id in fc_rows: + _append_assignment( + entry, + role="tenant_admin", + actions=sorted(entry["actions"]), + scope_type=scope_type, + scope_id=scope_id, + source="tenant_admin", + ) + else: + entry["unrestricted"] = True return services @@ -148,8 +173,8 @@ def _apply_tenant_admin_fc_entitlements(user, services): def resolve_service_entitlements_for_user(user): """ Build normalized per-service entitlements for a user by combining: - 1) Legacy action grants (CustomPermissions + GroupCustomPermissions) - 2) Scoped assignments (ServiceScopeAssignment) + 1) Tenant-wide auto-grants (non-FC) + tenant-admin FC scope + 2) Scoped assignments (ServiceScopeAssignment / Role Grants) Output keeps both: - flattened compatibility fields (`roles`, `actions`, `scopes`) @@ -160,48 +185,10 @@ def resolve_service_entitlements_for_user(user): else: services = {} - services = _apply_tenant_admin_fc_entitlements(user, services) + services = _apply_tenant_user_entitlements(user, services) group_ids = list(user.groups.values_list("id", flat=True)) - # Legacy action grants via groups. - group_rows = ( - GroupCustomPermissions.objects - .filter( - group_id__in=group_ids, status=1, - permission_names__status=1, - permission_names__service__status=1, - ) - .values_list( - "permission_names__service__service_code", - "permission_names__service__service_name", - "permission_names__action", - ) - ) - for service_code, service_name, action in group_rows: - if service_code and action: - entry = _ensure_service_entry(services, service_code, service_name) - entry["actions"].add(action) - - # Legacy action grants via direct user permissions. - user_rows = ( - CustomPermissions.objects - .filter( - user=user, status=1, - permission_name__status=1, - permission_name__service__status=1, - ) - .values_list( - "permission_name__service__service_code", - "permission_name__service__service_name", - "permission_name__action", - ) - ) - for service_code, service_name, action in user_rows: - if service_code and action: - entry = _ensure_service_entry(services, service_code, service_name) - entry["actions"].add(action) - # Scoped assignments via group or user. scope_assignments = ( ServiceScopeAssignment.objects diff --git a/aegis/signals.py b/aegis/signals.py index e69de29..b0378f7 100644 --- a/aegis/signals.py +++ b/aegis/signals.py @@ -0,0 +1,18 @@ +# aegis/signals.py + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from aegis.models import PermissionMaster, ServiceMaster + + +@receiver(post_save, sender=ServiceMaster) +def seed_standard_permissions(sender, instance, created, **kwargs): + if not created: + return + for action, _ in PermissionMaster.ACTION_CHOICES: + PermissionMaster.objects.get_or_create( + service=instance, + action=action, + defaults={"status": 1}, + ) diff --git a/aegis/static/aegis/admin/service_scope_assignment.js b/aegis/static/aegis/admin/service_scope_assignment.js index 6d9291d..98d8985 100644 --- a/aegis/static/aegis/admin/service_scope_assignment.js +++ b/aegis/static/aegis/admin/service_scope_assignment.js @@ -11,24 +11,10 @@ function setRowVisibility(fieldName, visible) { var row = findRow(fieldName); - var input = findInput(fieldName); if (!row) { return; } row.style.display = visible ? "" : "none"; - if (input) { - input.disabled = !visible; - } - } - - function syncSubjectFields() { - var subjectType = findInput("subject_type"); - if (!subjectType) { - return; - } - var isGroup = subjectType.value === "group"; - setRowVisibility("subject_group", isGroup); - setRowVisibility("subject_user", !isGroup); } function syncScopeFields() { @@ -38,20 +24,13 @@ } var isFarm = scopeType.value === "farm"; var isParcel = scopeType.value === "parcel"; - setRowVisibility("scope_farm", isFarm); - setRowVisibility("scope_parcel", isParcel); + setRowVisibility("scope_farms", isFarm); + setRowVisibility("scope_parcels", isParcel); } function init() { - var subjectType = findInput("subject_type"); var scopeType = findInput("scope_type"); - - syncSubjectFields(); syncScopeFields(); - - if (subjectType) { - subjectType.addEventListener("change", syncSubjectFields); - } if (scopeType) { scopeType.addEventListener("change", syncScopeFields); } diff --git a/aegis/tests.py b/aegis/tests.py index e69de29..d16f00d 100644 --- a/aegis/tests.py +++ b/aegis/tests.py @@ -0,0 +1,25 @@ +from django.test import SimpleTestCase + +from aegis.views.api.service_registry_views import _has_fc_service_wide_action + + +class FarmCalendarServiceWideActionTests(SimpleTestCase): + def test_tenant_admin_can_use_flattened_service_action_without_scope_assignments(self): + entitlement = { + "roles": ["tenant_admin"], + "actions": ["add", "delete", "edit", "view"], + "assignments": [], + "unrestricted": False, + } + + self.assertTrue(_has_fc_service_wide_action(entitlement, "add")) + + def test_scoped_role_does_not_get_service_wide_action_from_flattened_actions(self): + entitlement = { + "roles": ["Viewer"], + "actions": ["view"], + "assignments": [], + "unrestricted": False, + } + + self.assertFalse(_has_fc_service_wide_action(entitlement, "view")) diff --git a/aegis/throttles.py b/aegis/throttles.py new file mode 100644 index 0000000..d607761 --- /dev/null +++ b/aegis/throttles.py @@ -0,0 +1,91 @@ +import hashlib + +from django.conf import settings +from django.core.cache import cache + + +def _login_attempt_limit(): + return max(1, int(getattr(settings, "LOGIN_THROTTLE_ATTEMPTS", 5))) + + +def _login_attempt_window_seconds(): + return max(60, int(getattr(settings, "LOGIN_THROTTLE_WINDOW_SECONDS", 600))) + + +def _cache_key(kind, value): + digest = hashlib.sha256((value or "").encode("utf-8")).hexdigest() + return f"gk:login_throttle:{kind}:{digest}" + + +def _get_client_ip(request): + forwarded_for = (request.META.get("HTTP_X_FORWARDED_FOR") or "").strip() + if forwarded_for: + return forwarded_for.split(",")[0].strip() + return (request.META.get("REMOTE_ADDR") or "").strip() or "unknown" + + +def _normalize_identifier(identifier): + return (identifier or "").strip().lower() + + +def _read_counter(key): + payload = cache.get(key) + if not isinstance(payload, dict): + return 0 + return int(payload.get("count", 0)) + + +def _bump_counter(key): + window = _login_attempt_window_seconds() + payload = cache.get(key) + count = 1 + if isinstance(payload, dict): + count = int(payload.get("count", 0)) + 1 + cache.set(key, {"count": count}, timeout=window) + return count + + +def _clear_counter(key): + cache.delete(key) + + +def _retry_after_seconds(key): + remaining = cache.ttl(key) + if remaining is None: + return _login_attempt_window_seconds() + return max(1, int(remaining)) + + +def check_login_allowed(request, identifier=""): + limit = _login_attempt_limit() + ip_key = _cache_key("ip", _get_client_ip(request)) + keys = [ip_key] + + normalized_identifier = _normalize_identifier(identifier) + if normalized_identifier: + keys.append(_cache_key("identifier", normalized_identifier)) + + blocked_key = next((key for key in keys if _read_counter(key) >= limit), None) + if blocked_key: + return False, _retry_after_seconds(blocked_key) + return True, None + + +def register_login_failure(request, identifier=""): + ip_key = _cache_key("ip", _get_client_ip(request)) + _bump_counter(ip_key) + + normalized_identifier = _normalize_identifier(identifier) + if normalized_identifier: + identifier_key = _cache_key("identifier", normalized_identifier) + _bump_counter(identifier_key) + + +def clear_login_failures(request, identifier=""): + ip_key = _cache_key("ip", _get_client_ip(request)) + _clear_counter(ip_key) + + normalized_identifier = _normalize_identifier(identifier) + if normalized_identifier: + identifier_key = _cache_key("identifier", normalized_identifier) + _clear_counter(identifier_key) diff --git a/aegis/views/api/auth_views.py b/aegis/views/api/auth_views.py index 8f2c39d..be5dbd5 100644 --- a/aegis/views/api/auth_views.py +++ b/aegis/views/api/auth_views.py @@ -21,6 +21,7 @@ from aegis.models import BlacklistedRefresh, BlacklistedAccess, FarmCalendarResourceCache from aegis.serializers import CustomTokenObtainPairSerializer from aegis.services.entitlement_service import resolve_service_entitlements_for_user +from aegis.throttles import check_login_allowed, clear_login_failures, register_login_failure logging.basicConfig(level=logging.ERROR) logger = logging.getLogger(__name__) @@ -36,6 +37,29 @@ def _is_fc_service_payload(service_payload): class LoginAPIView(TokenObtainPairView): serializer_class = CustomTokenObtainPairSerializer + def post(self, request, *args, **kwargs): + identifier = (request.data.get("username") or "").strip() + allowed, retry_after = check_login_allowed(request, identifier=identifier) + if not allowed: + response = Response( + { + "error": ( + "Too many login attempts. " + f"Try again in {retry_after} seconds." + ) + }, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + response["Retry-After"] = str(retry_after) + return response + + response = super().post(request, *args, **kwargs) + if response.status_code == status.HTTP_200_OK: + clear_login_failures(request, identifier=identifier) + elif response.status_code in {status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED}: + register_login_failure(request, identifier=identifier) + return response + # @method_decorator(never_cache, name='dispatch') # class RegisterAPIView(APIView): diff --git a/aegis/views/api/service_registry_views.py b/aegis/views/api/service_registry_views.py index efe110c..479a6b3 100644 --- a/aegis/views/api/service_registry_views.py +++ b/aegis/views/api/service_registry_views.py @@ -209,6 +209,57 @@ def _get_fc_entitlement(user): return None +def _build_scope_action_maps(fc_entitlement) -> tuple[dict[str, set[str]], dict[str, set[str]]]: + """ + Map each farm/parcel scope_id to the actions granted ON THAT specific scope, + read from the entitlement's per-grant ``assignments``. This preserves the + action<->scope binding that the flattened ``actions``/``scopes`` fields drop. + """ + farm_actions: dict[str, set[str]] = {} + parcel_actions: dict[str, set[str]] = {} + for assignment in fc_entitlement.get("assignments", []) or []: + scope_type = assignment.get("scope_type") + scope_id = assignment.get("scope_id") + actions = {a for a in (assignment.get("actions") or []) if isinstance(a, str) and a} + if not scope_id or not actions: + continue + if scope_type == "farm": + farm_actions.setdefault(str(scope_id), set()).update(actions) + elif scope_type == "parcel": + parcel_actions.setdefault(str(scope_id), set()).update(actions) + return farm_actions, parcel_actions + + +def _scopes_with_action( + action: Optional[str], + farm_actions: dict[str, set[str]], + parcel_actions: dict[str, set[str]], +) -> tuple[set[str], set[str]]: + """Return (farms, parcels) where the given action is granted on that scope.""" + if not action: + return set(), set() + farms = {scope_id for scope_id, actions in farm_actions.items() if action in actions} + parcels = {scope_id for scope_id, actions in parcel_actions.items() if action in actions} + return farms, parcels + + +def _has_fc_service_wide_action(fc_entitlement, action: Optional[str]) -> bool: + if not action: + return False + + roles = { + role + for role in (fc_entitlement.get("roles") or []) + if isinstance(role, str) + } + actions = { + item + for item in (fc_entitlement.get("actions") or []) + if isinstance(item, str) + } + return action in actions and bool(roles.intersection({"admin", "tenant_admin"})) + + def _parcel_to_farm_map(): mapping: dict[str, Optional[str]] = {} for resource_id, farm_id in ( @@ -847,9 +898,13 @@ def dispatch_request(self, request, path): ) if fc_entitlement and not fc_entitlement.get("unrestricted"): required_action = METHOD_ACTION_MAP.get(method) - allowed_actions = set(fc_entitlement.get("actions", []) or []) - allowed_farms = set(fc_entitlement.get("scopes", {}).get("farm", []) or []) - allowed_parcels = set(fc_entitlement.get("scopes", {}).get("parcel", []) or []) + farm_actions, parcel_actions = _build_scope_action_maps(fc_entitlement) + has_service_wide_action = _has_fc_service_wide_action(fc_entitlement, required_action) + allowed_actions = set().union( + *farm_actions.values(), *parcel_actions.values() + ) if (farm_actions or parcel_actions) else set() + if has_service_wide_action and required_action: + allowed_actions.add(required_action) if required_action and required_action not in allowed_actions: LOG.warning( @@ -878,22 +933,28 @@ def dispatch_request(self, request, path): parcel_to_farm = _parcel_to_farm_map() farm_tenants, parcel_tenants = _resource_tenant_maps() tenant_id = str(request.user.tenant_id) if getattr(request.user, "tenant_id", None) else None - if not _targets_within_scope( + # Bind the scope check to the required action: only scopes + # that grant THIS action count as allowed for this write. + action_farms, action_parcels = _scopes_with_action( + required_action, farm_actions, parcel_actions + ) + if not has_service_wide_action and not _targets_within_scope( target_farms, target_parcels, - allowed_farms, - allowed_parcels, + action_farms, + action_parcels, parcel_to_farm, tenant_id, farm_tenants, parcel_tenants, ): LOG.warning( - "GK PROXY DENY method=%s svc=%s path=%s user=%s reason=scope target_farms=%s target_parcels=%s corr=%s", + "GK PROXY DENY method=%s svc=%s path=%s user=%s reason=scope_action required=%s target_farms=%s target_parcels=%s corr=%s", method, service_entry.service_name, path, getattr(request.user, "username", "-"), + required_action, sorted(target_farms), sorted(target_parcels), corr_id, @@ -957,8 +1018,11 @@ def dispatch_request(self, request, path): ): fc_entitlement = _get_fc_entitlement(request.user) if fc_entitlement and not fc_entitlement.get("unrestricted"): - allowed_farms = set(fc_entitlement.get("scopes", {}).get("farm", []) or []) - allowed_parcels = set(fc_entitlement.get("scopes", {}).get("parcel", []) or []) + farm_actions, parcel_actions = _build_scope_action_maps(fc_entitlement) + # Only surface scopes where the user can actually 'view'. + allowed_farms, allowed_parcels = _scopes_with_action( + "view", farm_actions, parcel_actions + ) tenant_id = str(request.user.tenant_id) if getattr(request.user, "tenant_id", None) else None response_data = resp.json() filtered_payload, changed = _filter_fc_payload(response_data, allowed_farms, allowed_parcels, tenant_id) diff --git a/aegis/views/auth_views.py b/aegis/views/auth_views.py index 0519b31..cbdc159 100644 --- a/aegis/views/auth_views.py +++ b/aegis/views/auth_views.py @@ -17,6 +17,7 @@ from urllib.parse import urlencode, urlparse, urlunparse, parse_qs from aegis.forms import UserLoginForm +from aegis.throttles import check_login_allowed, clear_login_failures, register_login_failure @method_decorator(never_cache, name='dispatch') @@ -49,6 +50,11 @@ def post(self, request, *args, **kwargs): password = form.cleaned_data["password"] # service_name = form.cleaned_data["service_name"] + allowed, retry_after = check_login_allowed(request, identifier=username) + if not allowed: + form.add_error(None, f"Too many login attempts. Try again in {retry_after} seconds.") + return self.render_to_response(self.get_context_data(form=form)) + login_url = f"{settings.INTERNAL_GK_URL}api/login/" try: @@ -64,6 +70,7 @@ def post(self, request, *args, **kwargs): data = response.json() access_token = data["access"] refresh_token = data["refresh"] + clear_login_failures(request, identifier=username) # Determine the redirect URL if next_url == "FarmCalendar": @@ -87,6 +94,8 @@ def post(self, request, *args, **kwargs): return HttpResponseRedirect(redirect_url) else: + if response.status_code in {status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED, status.HTTP_429_TOO_MANY_REQUESTS}: + register_login_failure(request, identifier=username) form.add_error(None, "Invalid credentials") return self.render_to_response(self.get_context_data(form=form)) diff --git a/gatekeeper/settings.py b/gatekeeper/settings.py index 9242a3b..78aebaf 100644 --- a/gatekeeper/settings.py +++ b/gatekeeper/settings.py @@ -24,6 +24,8 @@ JWT_ALG = os.environ.get('JWT_ALG', "HS256") JWT_ACCESS_TOKEN_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_MINUTES", "60")) JWT_REFRESH_TOKEN_DAYS = int(os.getenv("JWT_REFRESH_TOKEN_DAYS", "30")) +LOGIN_THROTTLE_ATTEMPTS = int(os.getenv("LOGIN_THROTTLE_ATTEMPTS", "5")) +LOGIN_THROTTLE_WINDOW_SECONDS = int(os.getenv("LOGIN_THROTTLE_WINDOW_SECONDS", "600")) # geting from env var from now, but in the future this infos should # come with the service registration post request diff --git a/requirements.txt b/requirements.txt index 88d7c89..677972f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,41 +1,41 @@ asgiref==3.11.1 -certifi==2026.2.25 +certifi==2026.6.17 cffi==2.0.0 charset-normalizer==3.4.7 crispy-bootstrap4==2026.2 -cryptography==46.0.7 +cryptography==49.0.0 dj-database-url==3.1.2 -Django==6.0.4 +Django==6.0.6 django-cors-headers==4.9.0 django-crispy-forms==2.6 -django-oauth-toolkit==3.2.0 -django-simple-history==3.11.0 +django-oauth-toolkit==3.3.0 +django-simple-history==3.12.0 djangorestframework==3.17.1 djangorestframework_simplejwt==5.5.1 drf-yasg==1.21.15 -idna==3.12 +idna==3.18 inflection==0.5.1 -jwcrypto==1.5.7 +jwcrypto==1.5.8 mysqlclient==2.2.8 -numpy==2.4.4 +numpy==2.5.0 oauthlib==3.3.1 -packaging==26.1 -pandas==3.0.2 +packaging==26.2 +pandas==3.0.3 psycopg2==2.9.12 pycparser==3.0 -PyJWT==2.12.1 +PyJWT==2.13.0 python-dateutil==2.9.0.post0 python-dotenv==1.2.2 -pytz==2026.1.post1 +pytz==2026.2 PyYAML==6.0.3 -requests==2.33.1 +requests==2.34.2 requests-mock==1.12.1 setuptools==82.0.1 six==1.17.0 sqlparse==0.5.5 -typing_extensions==4.15.0 -tzdata==2026.1 +typing_extensions==4.16.0 +tzdata==2026.2 uritemplate==4.2.0 -urllib3==2.6.3 +urllib3==2.7.0 waitress==3.0.2 whitenoise==6.12.0