diff --git a/geonode/base/api/tests.py b/geonode/base/api/tests.py index b39b03fc4ad..688c5f24cbc 100644 --- a/geonode/base/api/tests.py +++ b/geonode/base/api/tests.py @@ -2357,6 +2357,44 @@ def test_manager_can_edit_map(self): resource_perm_spec, ) + @override_settings( + DEFAULT_ANONYMOUS_PERMISSIONS="download", + DEFAULT_REGISTERED_MEMBERS_PERMISSIONS="edit", + ) + def test_resource_service_permissions_default_groups_from_compact_settings(self): + self.assertTrue(self.client.login(username="admin", password="admin")) + admin = get_user_model().objects.get(username="admin") + dataset = dataset_manager.create( + str(uuid4()), resource_type=Dataset, defaults={"title": "api_perms_compact_default", "owner": admin} + ) + url = reverse("base-resources-perms-spec", kwargs={"pk": dataset.pk}) + response = self.client.get(url, format="json") + self.assertEqual(response.status_code, 200) + + group_permissions = {g["name"]: g["permissions"] for g in response.data.get("groups", [])} + self.assertEqual(group_permissions.get("anonymous"), "download") + self.assertEqual(group_permissions.get("registered-members"), "edit") + + @override_settings( + DEFAULT_ANONYMOUS_PERMISSIONS=None, + DEFAULT_ANONYMOUS_VIEW_PERMISSION=True, + DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION=False, + DEFAULT_REGISTERED_MEMBERS_PERMISSIONS=None, + ) + def test_resource_service_permissions_default_groups_from_legacy_settings(self): + self.assertTrue(self.client.login(username="admin", password="admin")) + admin = get_user_model().objects.get(username="admin") + dataset = dataset_manager.create( + str(uuid4()), resource_type=Dataset, defaults={"title": "api_perms_legacy_default", "owner": admin} + ) + url = reverse("base-resources-perms-spec", kwargs={"pk": dataset.pk}) + response = self.client.get(url, format="json") + self.assertEqual(response.status_code, 200) + + group_permissions = {g["name"]: g["permissions"] for g in response.data.get("groups", [])} + self.assertEqual(group_permissions.get("anonymous"), "view") + self.assertEqual(group_permissions.get("registered-members"), "none") + @override_settings( EDITORS_CAN_MANAGE_ANONYMOUS_PERMISSIONS=False, EDITORS_CAN_MANAGE_REGISTERED_MEMBERS_PERMISSIONS=False, diff --git a/geonode/context_processors.py b/geonode/context_processors.py index c600fafa42b..0e4c20eb49c 100644 --- a/geonode/context_processors.py +++ b/geonode/context_processors.py @@ -27,6 +27,11 @@ from geonode.notifications_helper import has_notifications from geonode.base.models import Configuration, Thesaurus from geonode.utils import get_geonode_app_types +from geonode.security.permissions import ( + DOWNLOAD_RIGHTS, + VIEW_RIGHTS, + get_default_anonymous_compact_permission, +) from allauth.socialaccount.models import SocialApp @@ -34,6 +39,9 @@ def resource_urls(request): """Global values to pass to templates""" site = Site.objects.get_current() + anonymous_compact = get_default_anonymous_compact_permission() + default_anonymous_view = anonymous_compact in (VIEW_RIGHTS, DOWNLOAD_RIGHTS) + default_anonymous_download = anonymous_compact == DOWNLOAD_RIGHTS thesaurus = Thesaurus.objects.filter(facet=True).all().order_by("order", "id") if hasattr(settings, "THESAURUS"): warnings.warn( @@ -76,8 +84,8 @@ def resource_urls(request): LICENSES_METADATA=getattr(settings, "LICENSES", dict()).get("METADATA", "never"), USE_GEOSERVER=getattr(settings, "USE_GEOSERVER", False), USE_NOTIFICATIONS=has_notifications, - DEFAULT_ANONYMOUS_VIEW_PERMISSION=getattr(settings, "DEFAULT_ANONYMOUS_VIEW_PERMISSION", False), - DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION=getattr(settings, "DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION", False), + DEFAULT_ANONYMOUS_VIEW_PERMISSION=default_anonymous_view, + DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION=default_anonymous_download, EXIF_ENABLED=getattr(settings, "EXIF_ENABLED", False), FAVORITE_ENABLED=getattr(settings, "FAVORITE_ENABLED", False), THESAURI_FILTERS=( diff --git a/geonode/geoserver/manager.py b/geonode/geoserver/manager.py index fd4889cfce1..902f6ced2e2 100644 --- a/geonode/geoserver/manager.py +++ b/geonode/geoserver/manager.py @@ -35,6 +35,9 @@ from geonode.services.enumerations import CASCADED from geonode.security.utils import skip_registered_members_common_group from geonode.security.permissions import ( + get_default_anonymous_compact_permission, + VIEW_RIGHTS, + DOWNLOAD_RIGHTS, VIEW_PERMISSIONS, OWNER_PERMISSIONS, DOWNLOAD_PERMISSIONS, @@ -244,13 +247,13 @@ def set_permissions( if not skip_registered_members_common_group(user_group): create_geofence_rules(_resource, perms, None, user_group, batch) exist_geolimits = exist_geolimits or has_geolimits(_resource, None, user_group) - # Anonymous - if settings.DEFAULT_ANONYMOUS_VIEW_PERMISSION: + anonymous_compact = get_default_anonymous_compact_permission() + if anonymous_compact in (VIEW_RIGHTS, DOWNLOAD_RIGHTS): create_geofence_rules(_resource, VIEW_PERMISSIONS, None, None, batch) exist_geolimits = exist_geolimits or has_geolimits(_resource, None, None) - if settings.DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION: + if anonymous_compact == DOWNLOAD_RIGHTS: create_geofence_rules(_resource, DOWNLOAD_PERMISSIONS, None, None, batch) exist_geolimits = exist_geolimits or has_geolimits(_resource, None, None) diff --git a/geonode/resource/manager.py b/geonode/resource/manager.py index f06879eec91..4a98a3d8692 100644 --- a/geonode/resource/manager.py +++ b/geonode/resource/manager.py @@ -250,12 +250,9 @@ def finalize_creation_permissions( ) -> bool: """ Finalize default permissions for newly created resources, - including optional creation-time ownership handling. """ if not instance: return False - if not getattr(settings, "AUTO_ASSIGN_RESOURCE_OWNERSHIP_TO_ADMIN", False): - return False instance.set_default_permissions(owner=owner or instance.owner, created=True, initial_user=initial_user) return True diff --git a/geonode/security/handlers.py b/geonode/security/handlers.py index 2610458147b..e5afb9e27d3 100644 --- a/geonode/security/handlers.py +++ b/geonode/security/handlers.py @@ -18,7 +18,14 @@ ######################################################################### from abc import ABC from django.conf import settings -from geonode.security.permissions import _to_extended_perms, MANAGE_RIGHTS +from geonode.security.permissions import ( + _to_extended_perms, + get_default_anonymous_compact_permission, + get_default_registered_members_compact_permission, + MANAGE_RIGHTS, +) +from geonode.groups.conf import settings as groups_settings +from django.contrib.auth.models import Group class BasePermissionsHandler(ABC): @@ -95,6 +102,42 @@ def _has_edit(perms_list, u): return perms_copy +class DefaultSpecialGroupsPermissionsHandler(BasePermissionsHandler): + """ + Auto-assign configured permissions to anonymous and registered members groups on creation. + """ + + @staticmethod + def fixup_perms(instance, perms_payload, include_virtual=True, *args, **kwargs): + if not kwargs.get("created", False): + return perms_payload + + payload = perms_payload or {} + payload.setdefault("groups", {}) + + _resource_type = getattr(instance, "resource_type", None) or instance.polymorphic_ctype.name + _resource_subtype = (getattr(instance, "subtype", None) or "").lower() + + anonymous_compact = get_default_anonymous_compact_permission() + anonymous_group, _ = Group.objects.get_or_create(name="anonymous") + payload["groups"][anonymous_group] = sorted( + _to_extended_perms(anonymous_compact, _resource_type, _resource_subtype) + ) + + registered_compact = get_default_registered_members_compact_permission() + try: + registered_group = Group.objects.get(name=groups_settings.REGISTERED_MEMBERS_GROUP_NAME) + except Group.DoesNotExist: + registered_group = None + + if registered_group: + payload["groups"][registered_group] = sorted( + _to_extended_perms(registered_compact, _resource_type, _resource_subtype) + ) + + return payload + + class AdvancedWorkflowPermissionsHandler(BasePermissionsHandler): """ Handler that takes care of adjusting the permissions for the advanced workflow diff --git a/geonode/security/models.py b/geonode/security/models.py index 3cf9121837c..99b4771861b 100644 --- a/geonode/security/models.py +++ b/geonode/security/models.py @@ -25,7 +25,11 @@ from functools import reduce from django.db.models import Q -from django.conf import settings +from geonode.security.permissions import ( + get_default_anonymous_compact_permission, + VIEW_RIGHTS, + DOWNLOAD_RIGHTS, +) from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import Group, Permission @@ -198,7 +202,8 @@ def set_default_permissions(self, owner=None, created=False, **kwargs): user_groups = Group.objects.filter(name__in=_owner.groupmember_set.values_list("group__slug", flat=True)) # Anonymous - anonymous_can_view = settings.DEFAULT_ANONYMOUS_VIEW_PERMISSION + anonymous_compact = get_default_anonymous_compact_permission() + anonymous_can_view = anonymous_compact == VIEW_RIGHTS if anonymous_can_view: perm_spec["groups"][anonymous_group] = ["view_resourcebase"] else: @@ -211,7 +216,7 @@ def set_default_permissions(self, owner=None, created=False, **kwargs): ): perm_spec["groups"][user_group] = ["view_resourcebase"] - anonymous_can_download = settings.DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION + anonymous_can_download = anonymous_compact == DOWNLOAD_RIGHTS if anonymous_can_download: perm_spec["groups"][anonymous_group] = ["view_resourcebase", "download_resourcebase"] else: diff --git a/geonode/security/permissions.py b/geonode/security/permissions.py index 7ab61866744..1f43cd2754b 100644 --- a/geonode/security/permissions.py +++ b/geonode/security/permissions.py @@ -19,6 +19,7 @@ import copy import json +import logging import pprint import jsonschema import collections @@ -32,6 +33,9 @@ from geonode.utils import build_absolute_uri from geonode.groups.conf import settings as groups_settings +logger = logging.getLogger(__name__) + + """ Permissions will be managed according to a "compact" set: @@ -113,14 +117,6 @@ SERVICE_PERMISSIONS = ["add_service", "delete_service", "change_resourcebase_metadata", "add_resourcebase_from_service"] -DEFAULT_PERMISSIONS = [] -if settings.DEFAULT_ANONYMOUS_VIEW_PERMISSION: - DEFAULT_PERMISSIONS += VIEW_PERMISSIONS -if settings.DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION: - DEFAULT_PERMISSIONS += DOWNLOAD_PERMISSIONS - -DEFAULT_PERMS_SPEC = json.dumps({"users": {"AnonymousUser": DEFAULT_PERMISSIONS}, "groups": {}}) - NONE_RIGHTS = "none" VIEW_RIGHTS = "view" DOWNLOAD_RIGHTS = "download" @@ -136,6 +132,65 @@ (OWNER_RIGHTS, "owner"), ) +VALID_ANONYMOUS_COMPACT_PERMISSIONS = {VIEW_RIGHTS, DOWNLOAD_RIGHTS, NONE_RIGHTS} +VALID_REGISTERED_MEMBERS_COMPACT_PERMISSIONS = {VIEW_RIGHTS, DOWNLOAD_RIGHTS, EDIT_RIGHTS, MANAGE_RIGHTS, NONE_RIGHTS} + + +def _normalize_compact_permission(raw_value, valid_values, setting_name): + if raw_value is None: + return None + normalized_value = str(raw_value).strip().lower() + if normalized_value in ("", NONE_RIGHTS): + return None + if normalized_value not in valid_values: + logger.warning( + "%s contains unsupported value '%s'. Defaulting to 'none'.", + setting_name, + normalized_value, + ) + return None + return normalized_value + + +def get_default_anonymous_compact_permission(): + raw_value = getattr(settings, "DEFAULT_ANONYMOUS_PERMISSIONS", None) + if raw_value is not None: + return _normalize_compact_permission( + raw_value, + VALID_ANONYMOUS_COMPACT_PERMISSIONS, + "DEFAULT_ANONYMOUS_PERMISSIONS", + ) + legacy_download = getattr(settings, "DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION", True) + legacy_view = getattr(settings, "DEFAULT_ANONYMOUS_VIEW_PERMISSION", True) + if legacy_download: + return DOWNLOAD_RIGHTS + if legacy_view: + return VIEW_RIGHTS + return None + + +def get_default_registered_members_compact_permission(): + raw_value = getattr(settings, "DEFAULT_REGISTERED_MEMBERS_PERMISSIONS", None) + if raw_value is None: + return None + return _normalize_compact_permission( + raw_value, + VALID_REGISTERED_MEMBERS_COMPACT_PERMISSIONS, + "DEFAULT_REGISTERED_MEMBERS_PERMISSIONS", + ) + + +def get_default_anonymous_permissions_list(): + compact_perm = get_default_anonymous_compact_permission() + if compact_perm == VIEW_RIGHTS: + return VIEW_PERMISSIONS + if compact_perm == DOWNLOAD_RIGHTS: + return VIEW_PERMISSIONS + DOWNLOAD_PERMISSIONS + return [] + + +DEFAULT_PERMISSIONS = get_default_anonymous_permissions_list() +DEFAULT_PERMS_SPEC = json.dumps({"users": {"AnonymousUser": DEFAULT_PERMISSIONS}, "groups": {}}) PERM_SPEC_COMPACT_SCHEMA = { "$schema": "http://json-schema.org/draft-04/schema#", diff --git a/geonode/security/tests.py b/geonode/security/tests.py index 3d1c3057af7..47457523c88 100644 --- a/geonode/security/tests.py +++ b/geonode/security/tests.py @@ -49,7 +49,11 @@ from geonode.layers.models import Dataset from geonode.documents.models import Document from geonode.compat import ensure_string -from geonode.security.handlers import BasePermissionsHandler, GroupManagersPermissionsHandler +from geonode.security.handlers import ( + BasePermissionsHandler, + GroupManagersPermissionsHandler, + DefaultSpecialGroupsPermissionsHandler, +) from geonode.upload.models import ResourceHandlerInfo from geonode.utils import check_ogc_backend, build_absolute_uri from geonode.tests.utils import check_dataset @@ -60,6 +64,8 @@ from geonode.layers.populate_datasets_data import create_dataset_data from geonode.base.auth import create_auth_token, get_or_create_token from geonode.security.registry import permissions_registry +from geonode.groups.conf import settings as groups_settings +from geonode.security.permissions import _to_extended_perms from geonode.base.models import Configuration, UserGeoLimit, GroupGeoLimit from geonode.base.populate_test_data import ( @@ -3793,3 +3799,30 @@ def test_configuration_read_only_change_clears_permissions_cache(self): finally: config.read_only = original_read_only config.save() + + +class TestDefaultSpecialGroupsPermissionsHandler(GeoNodeBaseTestSupport): + @override_settings(DEFAULT_ANONYMOUS_PERMISSIONS="view", DEFAULT_REGISTERED_MEMBERS_PERMISSIONS="download") + def test_handler_sets_default_groups_on_create(self): + resource = create_single_dataset("test_default_special_groups") + handler = DefaultSpecialGroupsPermissionsHandler() + perms_payload = {"users": {}, "groups": {}} + + updated = handler.fixup_perms(resource, perms_payload, created=True) + + anonymous_group = Group.objects.get(name="anonymous") + registered_group, _ = Group.objects.get_or_create(name=groups_settings.REGISTERED_MEMBERS_GROUP_NAME) + expected_anonymous = _to_extended_perms("view", resource.resource_type, resource.subtype) + expected_registered = _to_extended_perms("download", resource.resource_type, resource.subtype) + + self.assertSetEqual(set(updated["groups"][anonymous_group]), set(expected_anonymous)) + self.assertSetEqual(set(updated["groups"][registered_group]), set(expected_registered)) + + def test_handler_skips_when_not_created(self): + resource = create_single_dataset("test_default_special_groups_skip") + handler = DefaultSpecialGroupsPermissionsHandler() + perms_payload = {"users": {}, "groups": {}} + + updated = handler.fixup_perms(resource, perms_payload, created=False) + + self.assertDictEqual(perms_payload, updated) diff --git a/geonode/security/utils.py b/geonode/security/utils.py index 8617ee4e92f..d6157d011e7 100644 --- a/geonode/security/utils.py +++ b/geonode/security/utils.py @@ -30,6 +30,9 @@ from geonode.groups.models import GroupProfile from geonode.security.registry import permissions_registry from geonode.security.permissions import ( + get_default_anonymous_compact_permission, + VIEW_RIGHTS, + DOWNLOAD_RIGHTS, PermSpecCompact, EDIT_PERMISSIONS, VIEW_PERMISSIONS, @@ -165,11 +168,11 @@ def get_user_visible_groups(user, include_public_invite: bool = False): class AdvancedSecurityWorkflowManager: @staticmethod def is_anonymous_can_view(): - return settings.DEFAULT_ANONYMOUS_VIEW_PERMISSION + return get_default_anonymous_compact_permission() in (VIEW_RIGHTS, DOWNLOAD_RIGHTS) @staticmethod def is_anonymous_can_download(): - return settings.DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION + return get_default_anonymous_compact_permission() == DOWNLOAD_RIGHTS @staticmethod def is_group_private_mode(): diff --git a/geonode/settings.py b/geonode/settings.py index 84a17ca31b7..4ab0f58afe0 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -1865,9 +1865,26 @@ def get_geonode_catalogue_service(): # Whether the uplaoded resources should be public and downloadable by default # or not +# DEPRECATED: use DEFAULT_ANONYMOUS_PERMISSIONS (compact permissions) DEFAULT_ANONYMOUS_VIEW_PERMISSION = ast.literal_eval(os.getenv("DEFAULT_ANONYMOUS_VIEW_PERMISSION", "True")) +# DEPRECATED: use DEFAULT_ANONYMOUS_PERMISSIONS (compact permissions) DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION = ast.literal_eval(os.getenv("DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION", "True")) +# Compact permissions for default groups +# Valid values: +# - DEFAULT_ANONYMOUS_PERMISSIONS: view | download | none +# - DEFAULT_REGISTERED_MEMBERS_PERMISSIONS: view | download | edit | manage | none +DEFAULT_ANONYMOUS_PERMISSIONS = os.getenv("DEFAULT_ANONYMOUS_PERMISSIONS", None) +DEFAULT_REGISTERED_MEMBERS_PERMISSIONS = os.getenv("DEFAULT_REGISTERED_MEMBERS_PERMISSIONS", None) + +if DEFAULT_ANONYMOUS_PERMISSIONS is None and ( + DEFAULT_ANONYMOUS_VIEW_PERMISSION is not True or DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION is not True +): + logger.warning( + "DEFAULT_ANONYMOUS_VIEW_PERMISSION and DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION are deprecated. " + "Please use DEFAULT_ANONYMOUS_PERMISSIONS instead." + ) + EDITORS_CAN_MANAGE_ANONYMOUS_PERMISSIONS = ast.literal_eval( os.getenv("EDITORS_CAN_MANAGE_ANONYMOUS_PERMISSIONS", "True") ) @@ -1878,6 +1895,7 @@ def get_geonode_catalogue_service(): PERMISSIONS_HANDLERS = [ "geonode.security.handlers.GroupManagersPermissionsHandler", "geonode.security.handlers.SpecialGroupsPermissionsHandler", + "geonode.security.handlers.DefaultSpecialGroupsPermissionsHandler", "geonode.security.handlers.AdvancedWorkflowPermissionsHandler", "geonode.security.handlers.AutoAssignResourceOwnershipHandler", ]