diff --git a/docs/src/setup/configuration/settings.md b/docs/src/setup/configuration/settings.md index e7224777959..a414ce6aa56 100644 --- a/docs/src/setup/configuration/settings.md +++ b/docs/src/setup/configuration/settings.md @@ -498,19 +498,79 @@ Additional download handlers that provides a link to download the resource One of the main features of debug mode is the display of detailed error pages. If your app raises an exception when DEBUG is True, Django will display a detailed traceback, including a lot of metadata about your environment, such as all the currently defined Django settings (from settings.py). This is a [Django Setting](https://docs.djangoproject.com/en/3.2/ref/settings/#debug) +**DEFAULT_ANONYMOUS_PERMISSIONS** + +: - Default ``None`` + - Env: ``DEFAULT_ANONYMOUS_PERMISSIONS`` + +Defines the default compact permission level assigned to anonymous users when a new resource is created. + +Supported values are: + +- ``view`` +- ``download`` +- ``none`` + +If this setting is not configured, GeoNode falls back to the deprecated ``DEFAULT_ANONYMOUS_VIEW_PERMISSION`` and ``DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION`` settings. + +Example: + +```python +DEFAULT_ANONYMOUS_PERMISSIONS = "download" +``` + +If an unsupported value is configured, GeoNode logs a warning and falls back to ``none``. + +**DEFAULT_REGISTERED_MEMBERS_PERMISSIONS** + +: - Default ``None`` + - Env: ``DEFAULT_REGISTERED_MEMBERS_PERMISSIONS`` + +Defines the default compact permission level assigned to the registered members group when a new resource is created. + +Supported values are: + +- ``view`` +- ``download`` +- ``edit`` +- ``manage`` +- ``none`` + +When set to ``none``, no default permissions are assigned to the registered members group. + +Example: + +```python +DEFAULT_REGISTERED_MEMBERS_PERMISSIONS = "edit" +``` + +If an unsupported value is configured, GeoNode logs a warning and falls back to ``none``. + [](){ #default-anonymous-download-permission } **DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION** -: Default: ``True`` +: - Default: ``True`` + - Env: ``DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION`` + +Deprecated. Use ``DEFAULT_ANONYMOUS_PERMISSIONS`` instead. + +Whether uploaded resources should be downloadable by anonymous users by default. + +This legacy setting is used only when ``DEFAULT_ANONYMOUS_PERMISSIONS`` is not configured. When ``DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION`` is ``True``, GeoNode treats the anonymous default compact permission as ``download``. -Whether the uploaded resources should downloadable by default. [](){ #default-anonymous-view-permission } **DEFAULT_ANONYMOUS_VIEW_PERMISSION** -: Default: ``True`` +: - Default: ``True`` + - Env: ``DEFAULT_ANONYMOUS_VIEW_PERMISSION`` + +Deprecated. Use ``DEFAULT_ANONYMOUS_PERMISSIONS`` instead. + +Whether uploaded resources should be visible to anonymous users by default. + +This legacy setting is used only when ``DEFAULT_ANONYMOUS_PERMISSIONS`` is not configured. When ``DEFAULT_ANONYMOUS_DOWNLOAD_PERMISSION`` is ``False`` and ``DEFAULT_ANONYMOUS_VIEW_PERMISSION`` is ``True``, GeoNode treats the anonymous default compact permission as ``view``. -Whether the uploaded resources should be public by default. **DEFAULT_DATASET_DOWNLOAD_HANDLER** 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 5c0b3fdd8f6..ab355f5632f 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", ]