Browse Source

Merge pull request #1585 from pbiering/add-permit_overwrite_collection

Add option permit_overwrite_collection
Peter Bieringer 1 year ago
parent
commit
e59e4d3aff
8 changed files with 67 additions and 4 deletions
  1. 1 0
      CHANGELOG.md
  2. 11 0
      DOCUMENTATION.md
  3. 2 0
      config
  4. 3 0
      radicale/app/__init__.py
  5. 2 1
      radicale/app/base.py
  6. 8 0
      radicale/app/put.py
  7. 4 0
      radicale/config.py
  8. 36 3
      radicale/tests/test_base.py

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@
 * Adjustment: option [auth] htpasswd_encryption change default from "md5" to "autodetect"
 * Adjustment: option [auth] htpasswd_encryption change default from "md5" to "autodetect"
 * Add: option [auth] type=ldap with (group) rights management via LDAP/LDAPS
 * 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'
 * 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
 ## 3.2.3
 * Add: support for Python 3.13
 * Add: support for Python 3.13

+ 11 - 0
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 False it can be permitted by permissions per section with: D
 If True it can be forbidden 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
 #### storage
 
 
 ##### type
 ##### type
@@ -1300,6 +1309,8 @@ The following `permissions` are recognized:
 * **w:** write address book and calendar collections
 * **w:** write address book and calendar collections
 * **D:** permit delete of collection in case permit_delete_collection=False
 * **D:** permit delete of collection in case permit_delete_collection=False
 * **d:** forbid delete of collection in case permit_delete_collection=True
 * **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
 ### Storage
 
 

+ 2 - 0
config

@@ -115,6 +115,8 @@
 # Permit delete of a collection (global)
 # Permit delete of a collection (global)
 #permit_delete_collection = True
 #permit_delete_collection = True
 
 
+# Permit overwrite of a collection (global)
+#permit_overwrite_collection = True
 
 
 [storage]
 [storage]
 
 

+ 3 - 0
radicale/app/__init__.py

@@ -70,6 +70,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
     _auth_realm: str
     _auth_realm: str
     _extra_headers: Mapping[str, str]
     _extra_headers: Mapping[str, str]
     _permit_delete_collection: bool
     _permit_delete_collection: bool
+    _permit_overwrite_collection: bool
 
 
     def __init__(self, configuration: config.Configuration) -> None:
     def __init__(self, configuration: config.Configuration) -> None:
         """Initialize Application.
         """Initialize Application.
@@ -91,6 +92,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
         self._auth_realm = configuration.get("auth", "realm")
         self._auth_realm = configuration.get("auth", "realm")
         self._permit_delete_collection = configuration.get("rights", "permit_delete_collection")
         self._permit_delete_collection = configuration.get("rights", "permit_delete_collection")
         logger.info("permit delete of collection: %s", self._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()
         self._extra_headers = dict()
         for key in self.configuration.options("headers"):
         for key in self.configuration.options("headers"):
             self._extra_headers[key] = configuration.get("headers", key)
             self._extra_headers[key] = configuration.get("headers", key)

+ 2 - 1
radicale/app/base.py

@@ -40,6 +40,7 @@ class ApplicationBase:
     _web: web.BaseWeb
     _web: web.BaseWeb
     _encoding: str
     _encoding: str
     _permit_delete_collection: bool
     _permit_delete_collection: bool
+    _permit_overwrite_collection: bool
     _hook: hook.BaseHook
     _hook: hook.BaseHook
 
 
     def __init__(self, configuration: config.Configuration) -> None:
     def __init__(self, configuration: config.Configuration) -> None:
@@ -125,7 +126,7 @@ class Access:
 
 
     def check(self, permission: str,
     def check(self, permission: str,
               item: Optional[types.CollectionOrItem] = None) -> bool:
               item: Optional[types.CollectionOrItem] = None) -> bool:
-        if permission not in "rwdD":
+        if permission not in "rwdDoO":
             raise ValueError("Invalid permission argument: %r" % permission)
             raise ValueError("Invalid permission argument: %r" % permission)
         if not item:
         if not item:
             permissions = permission + permission.upper()
             permissions = permission + permission.upper()

+ 8 - 0
radicale/app/put.py

@@ -177,6 +177,14 @@ class ApplicationPartPut(ApplicationBase):
             if write_whole_collection:
             if write_whole_collection:
                 if ("w" if tag else "W") not in access.permissions:
                 if ("w" if tag else "W") not in access.permissions:
                     return httputils.NOT_ALLOWED
                     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:
             elif "w" not in access.parent_permissions:
                 return httputils.NOT_ALLOWED
                 return httputils.NOT_ALLOWED
 
 

+ 4 - 0
radicale/config.py

@@ -245,6 +245,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
             "value": "True",
             "value": "True",
             "help": "permit delete of a collection",
             "help": "permit delete of a collection",
             "type": bool}),
             "type": bool}),
+        ("permit_overwrite_collection", {
+            "value": "True",
+            "help": "permit overwrite of a collection",
+            "type": bool}),
         ("file", {
         ("file", {
             "value": "/etc/radicale/rights",
             "value": "/etc/radicale/rights",
             "help": "file for rights management from_file",
             "help": "file for rights management from_file",

+ 36 - 3
radicale/tests/test_base.py

@@ -41,7 +41,6 @@ class TestBaseRequests(BaseTest):
     def setup_method(self) -> None:
     def setup_method(self) -> None:
         BaseTest.setup_method(self)
         BaseTest.setup_method(self)
         rights_file_path = os.path.join(self.colpath, "rights")
         rights_file_path = os.path.join(self.colpath, "rights")
-        self.configure({"rights": {"permit_delete_collection": True}})
         with open(rights_file_path, "w") as f:
         with open(rights_file_path, "w") as f:
             f.write("""\
             f.write("""\
 [permit delete collection]
 [permit delete collection]
@@ -54,6 +53,16 @@ user: .*
 collection: test-forbid-delete
 collection: test-forbid-delete
 permissions: RrWwd
 permissions: RrWwd
 
 
+[permit overwrite collection]
+user: .*
+collection: test-permit-overwrite
+permissions: RrWwO
+
+[forbid overwrite collection]
+user: .*
+collection: test-forbid-overwrite
+permissions: RrWwo
+
 [allow all]
 [allow all]
 user: .*
 user: .*
 collection: .*
 collection: .*
@@ -450,8 +459,8 @@ permissions: RrWw""")
         assert responses["/calendar.ics/"] == 200
         assert responses["/calendar.ics/"] == 200
         self.get("/calendar.ics/", check=404)
         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.configure({"rights": {"permit_delete_collection": False}})
         self.mkcalendar("/calendar.ics/")
         self.mkcalendar("/calendar.ics/")
         event = get_file_content("event1.ics")
         event = get_file_content("event1.ics")
@@ -488,6 +497,30 @@ permissions: RrWw""")
         self.get("/calendar.ics/", check=404)
         self.get("/calendar.ics/", check=404)
         self.get("/event1.ics", 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:
     def test_propfind(self) -> None:
         calendar_path = "/calendar.ics/"
         calendar_path = "/calendar.ics/"
         self.mkcalendar("/calendar.ics/")
         self.mkcalendar("/calendar.ics/")