diff --git a/CHANGELOG.md b/CHANGELOG.md index 08296c64a..b1134ffdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Adjustment: option [auth] htpasswd_encryption change default from "md5" to "autodetect" * Add: option [auth] type=ldap with (group) rights management via LDAP/LDAPS +* Enhancement: permit_delete_collection can be now controlled also per collection by rights 'D' or 'd' ## 3.2.3 * Add: support for Python 3.13 diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 61a56ebfc..46a9cd23d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -913,6 +913,9 @@ File for the rights backend `from_file`. See the Global control of permission to delete complete collection (default: True) +If False it can be permitted by permissions per section with: D +If True it can be forbidden by permissions per section with: d + #### storage ##### type @@ -1295,6 +1298,8 @@ The following `permissions` are recognized: (CalDAV/CardDAV is susceptible to expensive search requests) * **W:** write collections (excluding address books and calendars) * **w:** write address book and calendar collections +* **D:** permit delete of collection in case permit_delete_collection=False +* **d:** forbid delete of collection in case permit_delete_collection=True ### Storage diff --git a/radicale/app/base.py b/radicale/app/base.py index 5c8a93550..71fd80732 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -125,7 +125,7 @@ def parent_permissions(self) -> str: def check(self, permission: str, item: Optional[types.CollectionOrItem] = None) -> bool: - if permission not in "rw": + if permission not in "rwdD": raise ValueError("Invalid permission argument: %r" % permission) if not item: permissions = permission + permission.upper() diff --git a/radicale/app/delete.py b/radicale/app/delete.py index 53d9bfd36..ee7550ff4 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -3,6 +3,7 @@ # Copyright © 2008 Pascal Halter # Copyright © 2008-2017 Guillaume Ayoub # Copyright © 2017-2018 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -24,6 +25,7 @@ from radicale import httputils, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.hook import HookNotificationItem, HookNotificationItemTypes +from radicale.log import logger def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection, @@ -71,17 +73,22 @@ def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str, hook_notification_item_list = [] if isinstance(item, storage.BaseCollection): if self._permit_delete_collection: - for i in item.get_all(): - hook_notification_item_list.append( - HookNotificationItem( - HookNotificationItemTypes.DELETE, - access.path, - i.uid - ) - ) - xml_answer = xml_delete(base_prefix, path, item) + if access.check("d", item): + logger.info("delete of collection is permitted by config/option [rights] permit_delete_collection but explicit forbidden by permission 'd': %s", path) + return httputils.NOT_ALLOWED else: - return httputils.NOT_ALLOWED + if not access.check("D", item): + logger.info("delete of collection is prevented by config/option [rights] permit_delete_collection and not explicit allowed by permission 'D': %s", path) + return httputils.NOT_ALLOWED + for i in item.get_all(): + hook_notification_item_list.append( + HookNotificationItem( + HookNotificationItemTypes.DELETE, + access.path, + i.uid + ) + ) + xml_answer = xml_delete(base_prefix, path, item) else: assert item.collection is not None assert item.href is not None diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index fc708ebc0..b6046c1a4 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -41,8 +41,19 @@ class TestBaseRequests(BaseTest): def setup_method(self) -> None: BaseTest.setup_method(self) rights_file_path = os.path.join(self.colpath, "rights") + self.configure({"rights": {"permit_delete_collection": True}}) with open(rights_file_path, "w") as f: f.write("""\ +[permit delete collection] +user: .* +collection: test-permit-delete +permissions: RrWwD + +[forbid delete collection] +user: .* +collection: test-forbid-delete +permissions: RrWwd + [allow all] user: .* collection: .* @@ -439,6 +450,33 @@ def test_delete_collection(self) -> None: assert responses["/calendar.ics/"] == 200 self.get("/calendar.ics/", check=404) + def test_delete_collection_not_permitted(self) -> None: + """Delete a collection (try if not permitted).""" + self.configure({"rights": {"permit_delete_collection": False}}) + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + self.put("/calendar.ics/event1.ics", event) + _, responses = self.delete("/calendar.ics/", check=401) + self.get("/calendar.ics/", check=200) + + def test_delete_collection_global_forbid_explicit_permit(self) -> None: + """Delete a collection with permitted path (expect permit).""" + self.configure({"rights": {"permit_delete_collection": False}}) + self.mkcalendar("/test-permit-delete/") + event = get_file_content("event1.ics") + self.put("/test-permit-delete/event1.ics", event) + _, responses = self.delete("/test-permit-delete/", check=200) + self.get("/test-permit-delete/", check=404) + + def test_delete_collection_global_permit_explicit_forbid(self) -> None: + """Delete a collection with permitted path (expect forbid).""" + self.configure({"rights": {"permit_delete_collection": True}}) + self.mkcalendar("/test-forbid-delete/") + event = get_file_content("event1.ics") + self.put("/test-forbid-delete/event1.ics", event) + _, responses = self.delete("/test-forbid-delete/", check=401) + self.get("/test-forbid-delete/", check=200) + def test_delete_root_collection(self) -> None: """Delete the root collection.""" self.mkcalendar("/calendar.ics/")