diff --git a/CHANGELOG.md b/CHANGELOG.md index b1134ffd..e84e88f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,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' +* Add: option [rights] permit_overwrite_collection (default=True) which can be also controlled per collection by rights 'O' or 'o' ## 3.2.3 * Add: support for Python 3.13 diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 46a9cd23..64aff476 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -916,6 +916,15 @@ 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 +##### permit_overwrite_collection + +(New since 3.3.0) + +Global control of permission to overwrite complete collection (default: True) + +If False it can be permitted by permissions per section with: O +If True it can be forbidden by permissions per section with: o + #### storage ##### type @@ -1300,6 +1309,8 @@ The following `permissions` are recognized: * **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 +* **O:** permit overwrite of collection in case permit_overwrite_collection=False +* **o:** forbid overwrite of collection in case permit_overwrite_collection=True ### Storage diff --git a/config b/config index 5fb11290..67b4a969 100644 --- a/config +++ b/config @@ -115,6 +115,8 @@ # Permit delete of a collection (global) #permit_delete_collection = True +# Permit overwrite of a collection (global) +#permit_overwrite_collection = True [storage] diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index ee958ad4..4f11ad3f 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -70,6 +70,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, _auth_realm: str _extra_headers: Mapping[str, str] _permit_delete_collection: bool + _permit_overwrite_collection: bool def __init__(self, configuration: config.Configuration) -> None: """Initialize Application. @@ -91,6 +92,8 @@ def __init__(self, configuration: config.Configuration) -> None: self._auth_realm = configuration.get("auth", "realm") self._permit_delete_collection = configuration.get("rights", "permit_delete_collection") logger.info("permit delete of collection: %s", self._permit_delete_collection) + self._permit_overwrite_collection = configuration.get("rights", "permit_overwrite_collection") + logger.info("permit overwrite of collection: %s", self._permit_overwrite_collection) self._extra_headers = dict() for key in self.configuration.options("headers"): self._extra_headers[key] = configuration.get("headers", key) diff --git a/radicale/app/base.py b/radicale/app/base.py index 71fd8073..28b6f262 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -40,6 +40,7 @@ class ApplicationBase: _web: web.BaseWeb _encoding: str _permit_delete_collection: bool + _permit_overwrite_collection: bool _hook: hook.BaseHook def __init__(self, configuration: config.Configuration) -> None: @@ -125,7 +126,7 @@ def parent_permissions(self) -> str: def check(self, permission: str, item: Optional[types.CollectionOrItem] = None) -> bool: - if permission not in "rwdD": + if permission not in "rwdDoO": raise ValueError("Invalid permission argument: %r" % permission) if not item: permissions = permission + permission.upper() diff --git a/radicale/app/put.py b/radicale/app/put.py index 15a7e00d..710e4435 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -177,6 +177,14 @@ def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str, if write_whole_collection: if ("w" if tag else "W") not in access.permissions: return httputils.NOT_ALLOWED + if not self._permit_overwrite_collection: + if ("O") not in access.permissions: + logger.info("overwrite of collection is prevented by config/option [rights] permit_overwrite_collection and not explicit allowed by permssion 'O': %s", path) + return httputils.NOT_ALLOWED + else: + if ("o") in access.permissions: + logger.info("overwrite of collection is allowed by config/option [rights] permit_overwrite_collection but explicit forbidden by permission 'o': %s", path) + return httputils.NOT_ALLOWED elif "w" not in access.parent_permissions: return httputils.NOT_ALLOWED diff --git a/radicale/config.py b/radicale/config.py index 5bddaf91..3b4f4c7b 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -245,6 +245,10 @@ def json_str(value: Any) -> dict: "value": "True", "help": "permit delete of a collection", "type": bool}), + ("permit_overwrite_collection", { + "value": "True", + "help": "permit overwrite of a collection", + "type": bool}), ("file", { "value": "/etc/radicale/rights", "help": "file for rights management from_file", diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py index b6046c1a..c47df720 100644 --- a/radicale/tests/test_base.py +++ b/radicale/tests/test_base.py @@ -41,7 +41,6 @@ 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] @@ -54,6 +53,16 @@ def setup_method(self) -> None: collection: test-forbid-delete permissions: RrWwd +[permit overwrite collection] +user: .* +collection: test-permit-overwrite +permissions: RrWwO + +[forbid overwrite collection] +user: .* +collection: test-forbid-overwrite +permissions: RrWwo + [allow all] user: .* collection: .* @@ -450,8 +459,8 @@ 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).""" + def test_delete_collection_global_forbid(self) -> None: + """Delete a collection (expect forbidden).""" self.configure({"rights": {"permit_delete_collection": False}}) self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") @@ -488,6 +497,30 @@ def test_delete_root_collection(self) -> None: self.get("/calendar.ics/", check=404) self.get("/event1.ics", 404) + def test_overwrite_collection_global_forbid(self) -> None: + """Overwrite a collection (expect forbid).""" + self.configure({"rights": {"permit_overwrite_collection": False}}) + event = get_file_content("event1.ics") + self.put("/calender.ics/", event, check=401) + + def test_overwrite_collection_global_forbid_explict_permit(self) -> None: + """Overwrite a collection with permitted path (expect permit).""" + self.configure({"rights": {"permit_overwrite_collection": False}}) + event = get_file_content("event1.ics") + self.put("/test-permit-overwrite/", event, check=201) + + def test_overwrite_collection_global_permit(self) -> None: + """Overwrite a collection (expect permit).""" + self.configure({"rights": {"permit_overwrite_collection": True}}) + event = get_file_content("event1.ics") + self.put("/calender.ics/", event, check=201) + + def test_overwrite_collection_global_permit_explict_forbid(self) -> None: + """Overwrite a collection with forbidden path (expect forbid).""" + self.configure({"rights": {"permit_overwrite_collection": True}}) + event = get_file_content("event1.ics") + self.put("/test-forbid-overwrite/", event, check=401) + def test_propfind(self) -> None: calendar_path = "/calendar.ics/" self.mkcalendar("/calendar.ics/")