diff --git a/docs/src/setup/configuration/settings.md b/docs/src/setup/configuration/settings.md index 9850c3b1ce3..68df4843ecd 100644 --- a/docs/src/setup/configuration/settings.md +++ b/docs/src/setup/configuration/settings.md @@ -521,19 +521,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 c18ffa7ee67..882686e7c1a 100644 --- a/geonode/resource/manager.py +++ b/geonode/resource/manager.py @@ -249,7 +249,6 @@ def finalize_creation_permissions( ) -> bool: """ Finalize default permissions for newly created resources, - including optional creation-time ownership handling. """ if not instance: return False diff --git a/geonode/security/handlers.py b/geonode/security/handlers.py index c281feefcc6..2066c03598b 100644 --- a/geonode/security/handlers.py +++ b/geonode/security/handlers.py @@ -19,7 +19,17 @@ from abc import ABC import logging from django.conf import settings -from geonode.security.permissions import _to_extended_perms, VIEW_RIGHTS, DOWNLOAD_RIGHTS, EDIT_RIGHTS, MANAGE_RIGHTS +from geonode.security.permissions import ( + _to_extended_perms, + get_default_anonymous_compact_permission, + get_default_registered_members_compact_permission, + MANAGE_RIGHTS, + VIEW_RIGHTS, + DOWNLOAD_RIGHTS, + EDIT_RIGHTS, +) +from geonode.groups.conf import settings as groups_settings +from django.contrib.auth.models import Group logger = logging.getLogger(__name__) @@ -98,6 +108,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 a3b7ea00063..d00ea9e53c4 100644 --- a/geonode/security/tests.py +++ b/geonode/security/tests.py @@ -52,6 +52,7 @@ from geonode.security.handlers import ( BasePermissionsHandler, GroupManagersPermissionsHandler, + DefaultSpecialGroupsPermissionsHandler, ResourceCreatorGroupsPermissionsHandler, ) from geonode.upload.models import ResourceHandlerInfo @@ -64,6 +65,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.metadata.manager import metadata_manager from geonode.base.models import Configuration, UserGeoLimit, GroupGeoLimit @@ -3858,3 +3861,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 3ba59905ff2..c2bb8f5ed4c 100644 --- a/geonode/settings.py +++ b/geonode/settings.py @@ -1868,9 +1868,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") ) @@ -1881,6 +1898,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.ResourceCreatorGroupsPermissionsHandler", "geonode.security.handlers.AutoAssignResourceOwnershipHandler",