Merge pull request #2005 from pbiering/sharing-properties-overlay-proppatch

Sharing properties overlay via proppatch
This commit is contained in:
Peter Bieringer
2026-03-01 08:51:55 +01:00
committed by GitHub
11 changed files with 690 additions and 44 deletions

View File

@@ -1474,8 +1474,8 @@ Default: `/etc/radicale/rights`
_(>= 3.1.9)_ _(>= 3.1.9)_
Global permission to delete complete collections. Global permission to delete complete collections.
* If `False` it can be explicitly granted per collection by `permissions: D` * If `False` it can be explicitly granted per collection by *rights* permissions: `D`
* If `True` it can be explicitly forbidden per collection by `permissions: d` * If `True` it can be explicitly forbidden per collection by *rights* permissions: `d`
Default: `True` Default: `True`
@@ -1484,8 +1484,8 @@ Default: `True`
_(>= 3.3.0)_ _(>= 3.3.0)_
Global permission to overwrite complete collections. Global permission to overwrite complete collections.
* If `False` it can be explicitly granted per collection by `permissions: O` * If `False` it can be explicitly granted per collection by *rights* permissions: `O`
* If `True` it can be explicitly forbidden per collection by `permissions: o` * If `True` it can be explicitly forbidden per collection by *rights* permissions: `o`
Default: `True` Default: `True`
@@ -2110,8 +2110,8 @@ Permit create of token-based sharing
Default: `false` Default: `false`
* If `False` it can be explicitly granted by `permissions: t` * If `False` it can be explicitly granted by *rights* permissions: `T`
* If `True` it can be explicitly forbidden by `permissions: T` * If `True` it can be explicitly forbidden by *rights* permissions: `t`
##### permit_create_map ##### permit_create_map
@@ -2121,8 +2121,30 @@ Permit create of map-based sharing
Default: `false` Default: `false`
* If `False` it can be explicitly granted by `permissions: m` * If `False` it can be explicitly granted by *rights* permissions: `M`
* If `True` it can be explicitly forbidden by `permissions: M` * If `True` it can be explicitly forbidden by *rights* permissions: `m`
##### permit_properties_overlay
_(>= 3.7.0)_
Permit (limited) properties overlay by user of shared collection
Default: `false`
* If `False` it can be explicitly granted by *share* permissions: `P`
* If `True` it can be explicitly forbidden by *share* permissions: `p`
##### enforce_properties_overlay
_(>= 3.7.0)_
Enforce properties overlay even on write access
Default: `true`
* If `False` it can be explicitly enforced by *share* permissions: `E`
* If `True` it can be explicitly forbidden by *share* permissions: `e`
##### default_permissions_create_token ##### default_permissions_create_token
@@ -2130,12 +2152,16 @@ Default permissions for create token-based sharing
Default: `r` Default: `r`
Supported: `rwEePp`
##### default_permissions_create_map ##### default_permissions_create_map
Default permissions for map-based sharing Default permissions for map-based sharing
Default: `r` Default: `r`
Supported: `rwEePp`
## Supported Clients ## Supported Clients
Radicale has been tested with: Radicale has been tested with:

View File

@@ -58,6 +58,11 @@ File-based configuration store is using encoded `PathOrToken` as filename for ea
Map-based sharing can be accessed as usual after authentication and authorization. Map-based sharing can be accessed as usual after authentication and authorization.
#### Permission Control
* `permit_create_map`
* supported *rights* permissions: `Mm`
#### Workflow #### Workflow
* create map as owner * create map as owner
@@ -78,13 +83,17 @@ Token-based sharing can be accessed after retrieving the token via
Token-URI: `/.token/<Token>` Token-URI: `/.token/<Token>`
#### Permission Control
* `permit_create_token`
* supported *rights* permissions: `Tt`
#### Workflow #### Workflow
* create token as owner * create token as owner
* enable token as owner (can be combined with "create") * enable token as owner (can be combined with "create")
* handover URI with token to client * handover URI with token to client
## Sharing Configuration Management API ## Sharing Configuration Management API
### Sharing Configuration Management API version 1 ### Sharing Configuration Management API version 1
@@ -294,7 +303,14 @@ Owner or user can define per share a set of properties to overlay on PROPFIND re
Whitelisted ones are defined in `OVERLAY_PROPERTIES_WHITELIST` in `radicale/sharing/__init__.py`: Whitelisted ones are defined in `OVERLAY_PROPERTIES_WHITELIST` in `radicale/sharing/__init__.py`:
* `C:calendar-description` (_>= 3.7.0_) * `C:calendar-description` (_>= 3.7.0_)
* `ICAL:calendar-color` (_>= 3.7.0_) * `ICAL:calendar-color` (_>= 3.7.0_)
* `CR:addressbook-description` (_>= 3.7.0_) * `CR:addressbook-description` (_>= 3.7.0_)
* `INF:addressbook-color` (_>= 3.7.0_) * `INF:addressbook-color` (_>= 3.7.0_)
### Properties Overlay Control Options
* `permit_properties_overlay`
* supported *share* permissions: `Pp`
* `enforce_properties_overlay`
* supported *share* permissions: `Ee`

12
config
View File

@@ -327,10 +327,22 @@
# If True it can be explicitly forbidden by permissions: M # If True it can be explicitly forbidden by permissions: M
#permit_create_map = false #permit_create_map = false
# Permit properties overlay
# If False it can be explicitly granted by share permissions: P
# If True it can be explicitly forbidden by share permissions: p
#permit_properties_overlay = false
# Enforce properties overlay on write access
# If False it can be explicitly enforced by share permissions: E
# If True it can be explicitly forbidden by share permissions: e
#enforce_properties_overlay = true
# Default permissions for token-based sharing # Default permissions for token-based sharing
# Supported: rwEePp
#default_permissions_create_token = r #default_permissions_create_token = r
# Default permissions for map-based sharing # Default permissions for map-based sharing
# Supported: rwEePp
#default_permissions_create_map = r #default_permissions_create_map = r

View File

@@ -347,8 +347,10 @@ def xml_propfind_response(
if tag_text is not None: if tag_text is not None:
if sharing: if sharing:
# map from overlay # map from overlay
if sharing['Properties'][human_tag] is not None: if sharing['Properties']:
tag_text = sharing['Properties'][human_tag] if human_tag in sharing['Properties']:
if sharing['Properties'][human_tag] is not None:
tag_text = sharing['Properties'][human_tag]
element.text = tag_text element.text = tag_text
else: else:
is404 = True is404 = True

View File

@@ -29,7 +29,7 @@ from typing import Dict, Optional, Union, cast
import defusedxml.ElementTree as DefusedET import defusedxml.ElementTree as DefusedET
import radicale.item as radicale_item import radicale.item as radicale_item
from radicale import httputils, storage, types, xmlutils from radicale import httputils, sharing, storage, types, xmlutils
from radicale.app.base import Access, ApplicationBase from radicale.app.base import Access, ApplicationBase
from radicale.hook import HookNotificationItem, HookNotificationItemTypes from radicale.hook import HookNotificationItem, HookNotificationItemTypes
from radicale.log import logger from radicale.log import logger
@@ -37,7 +37,7 @@ from radicale.log import logger
def xml_proppatch(base_prefix: str, path: str, def xml_proppatch(base_prefix: str, path: str,
xml_request: Optional[ET.Element], xml_request: Optional[ET.Element],
collection: storage.BaseCollection, sharing: Union[dict, None] = None) -> ET.Element: collection: Union[storage.BaseCollection, None], sharing: Union[dict, None] = None, sharing_overlay: bool = False, _sharing: Union[sharing.BaseSharing, None] = None) -> ET.Element:
"""Read and answer PROPPATCH requests. """Read and answer PROPPATCH requests.
Read rfc4918-9.2 for info. Read rfc4918-9.2 for info.
@@ -62,11 +62,32 @@ def xml_proppatch(base_prefix: str, path: str,
response.append(propstat) response.append(propstat)
props_with_remove = xmlutils.props_from_request(xml_request) props_with_remove = xmlutils.props_from_request(xml_request)
all_props_with_remove = cast(Dict[str, Optional[str]], if sharing and sharing_overlay:
dict(collection.get_meta())) # PROPPATCH overlay adjustment
logger.debug("TRACE/PROPPATCH/xml_proppatch: sharing+sharing_overlay is active: %r", sharing)
if sharing['Properties'] is not None:
all_props_with_remove = cast(Dict[str, Optional[str]], radicale_item.check_and_sanitize_props(sharing['Properties']))
else:
all_props_with_remove = {}
all_props_with_remove.update(props_with_remove)
all_props = radicale_item.check_and_sanitize_props(all_props_with_remove)
logger.debug("TRACE/PROPPATCH/xml_proppatch: sharing+sharing_overlay result: %r", all_props)
else:
if collection is not None:
# always the case, but makes mypy happy
all_props_with_remove = cast(Dict[str, Optional[str]], dict(collection.get_meta()))
all_props_with_remove.update(props_with_remove) all_props_with_remove.update(props_with_remove)
all_props = radicale_item.check_and_sanitize_props(all_props_with_remove) all_props = radicale_item.check_and_sanitize_props(all_props_with_remove)
collection.set_meta(all_props) if sharing and sharing_overlay and _sharing is not None:
# _sharing is not None: always the case, but makes mypy happy
_sharing.update_sharing(ShareType=sharing['ShareType'],
PathOrToken=sharing['PathOrToken'],
OwnerOrUser=sharing['User'],
Properties=cast(Dict[str, str], all_props))
else:
if collection is not None:
# always the case, but makes mypy happy
collection.set_meta(all_props)
for short_name in props_with_remove: for short_name in props_with_remove:
props_ok.append(ET.Element(xmlutils.make_clark(short_name))) props_ok.append(ET.Element(xmlutils.make_clark(short_name)))
@@ -80,6 +101,8 @@ class ApplicationPartProppatch(ApplicationBase):
"""Manage PROPPATCH request.""" """Manage PROPPATCH request."""
permissions_filter = None permissions_filter = None
sharing = None sharing = None
sharing_overlay = False
path_orig = path
if self._sharing._enabled: if self._sharing._enabled:
# Sharing by token or map (if enabled) # Sharing by token or map (if enabled)
sharing = self._sharing.sharing_collection_resolver(path, user) sharing = self._sharing.sharing_collection_resolver(path, user)
@@ -90,7 +113,39 @@ class ApplicationPartProppatch(ApplicationBase):
permissions_filter = sharing['Permissions'] permissions_filter = sharing['Permissions']
access = Access(self._rights, user, path, permissions_filter) access = Access(self._rights, user, path, permissions_filter)
if not access.check("w"): if not access.check("w"):
return httputils.NOT_ALLOWED logger.debug("TRACE/PROPPATCH/xml_proppatch: no write-access: %r", path)
if sharing:
# no write access -> use properties overlay
if self._sharing.permit_properties_overlay:
if permissions_filter is not None and "p" in permissions_filter:
logger.info("PROPPATCH request on shared %r: no write-permissions, overlay permitted, but denied by permission 'p'", path_orig)
return httputils.NOT_ALLOWED
else:
logger.info("PROPPATCH request on shared %r: no write-permissions, overlay permitted by option", path_orig)
sharing_overlay = True
else:
if permissions_filter is not None and "P" in permissions_filter:
logger.info("PROPPATCH request on shared %r: no write-permissions, overlay denied, but granted by permission 'P'", path_orig)
sharing_overlay = True
else:
logger.info("PROPPATCH request on shared %r: no write-permissions and overlay denied by option", path_orig)
return httputils.NOT_ALLOWED
else:
return httputils.NOT_ALLOWED
else:
logger.debug("TRACE/PROPPATCH/xml_proppatch: write-access: %r", path)
if sharing:
# write access -> check for enforced properties overlay
logger.debug("TRACE/PROPPATCH/xml_proppatch: write-access/sharing: %r", path_orig)
if self._sharing.enforce_properties_overlay:
if permissions_filter is not None and "e" in permissions_filter:
logger.info("PROPPATCH request on shared %r: write-permissions, overlay enforced, but disabled by permission 'e'", path_orig)
else:
sharing_overlay = True
else:
if permissions_filter is not None and "E" in permissions_filter:
logger.info("PROPPATCH request on shared %r: write-permissions, overlay not enforced, but enforced by permission 'E'", path_orig)
sharing_overlay = True
try: try:
xml_content = self._read_xml_request_body(environ) xml_content = self._read_xml_request_body(environ)
except RuntimeError as e: except RuntimeError as e:
@@ -100,6 +155,38 @@ class ApplicationPartProppatch(ApplicationBase):
except socket.timeout: except socket.timeout:
logger.debug("Client timed out", exc_info=True) logger.debug("Client timed out", exc_info=True)
return httputils.REQUEST_TIMEOUT return httputils.REQUEST_TIMEOUT
if sharing_overlay:
# call API function internally and no not trigger any hook
headers = {"DAV": httputils.DAV_HEADERS,
"Content-Type": "text/xml; charset=%s" % self._encoding}
try:
xml_answer = xml_proppatch(base_prefix, path, xml_content,
None, sharing, sharing_overlay, self._sharing)
if xml_content is not None:
content = DefusedET.tostring(
xml_content,
encoding=self._encoding
).decode(encoding=self._encoding)
except ValueError as e:
# return better matching HTTP result in case errno is provided and catched
errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
if errno_match:
logger.error(
"Failed PROPPATCH request on %r: %s", path, e, exc_info=True)
errno_e = int(errno_match.group(1))
if errno_e == errno.ENOSPC:
return httputils.INSUFFICIENT_STORAGE
elif errno_e in [errno.EPERM, errno.EACCES]:
return httputils.FORBIDDEN
else:
return httputils.INTERNAL_SERVER_ERROR
else:
logger.warning(
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
return httputils.BAD_REQUEST
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)
with self._storage.acquire_lock("w", user, path=path, request="PROPPATCH"): with self._storage.acquire_lock("w", user, path=path, request="PROPPATCH"):
item = next(iter(self._storage.discover(path)), None) item = next(iter(self._storage.discover(path)), None)
if not item: if not item:

View File

@@ -491,6 +491,14 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"value": "false", "value": "false",
"help": "permit create of map-based sharing", "help": "permit create of map-based sharing",
"type": bool}), "type": bool}),
("permit_properties_overlay", {
"value": "false",
"help": "permit properties overlay",
"type": bool}),
("enforce_properties_overlay", {
"value": "true",
"help": "enforce properties overlay on write access",
"type": bool}),
("default_permissions_create_token", { ("default_permissions_create_token", {
"value": "r", "value": "r",
"help": "default permissions for token-based sharing", "help": "default permissions for token-based sharing",

View File

@@ -33,9 +33,15 @@ Permissions:
- o: deny overwriting a collection in case permit_overwrite_collection=True (>= 3.3.0) - o: deny overwriting a collection in case permit_overwrite_collection=True (>= 3.3.0)
- T: permit create of token-based sharing of collection in case permit_create_token=False (>= 3.7.0) - T: permit create of token-based sharing of collection in case permit_create_token=False (>= 3.7.0)
- t: deny create of token-based sharing of collection in case permit_create_token=True (>= 3.7.0) - t: deny create of token-based sharing of collection in case permit_create_token=True (>= 3.7.0)
- M: permit create of map-based sharing of collection in case permit_create_map= False (>= 3.7.0) - M: permit create of map-based sharing of collection in case permit_create_map=False (>= 3.7.0)
- m: deny create of map-based sharing of collection in case permit_create_map=True (>= 3.7.0) - m: deny create of map-based sharing of collection in case permit_create_map=True (>= 3.7.0)
Permissions only supported so far in share permissions:
- P: permit properties overlay in case permit_properties_overlay=False (>= 3.7.0)
- p: deny properties overlay in case permit_properties_overlay=True (>= 3.7.0)
- E: enable enforce properties overlay in case enforce_properties_overlay=False (>= 3.7.0)
- e: disable enforce of properties overlay in case enforce_properties_overlay=True (>= 3.7.0)
Take a look at the class ``BaseRights`` if you want to implement your own. Take a look at the class ``BaseRights`` if you want to implement your own.
""" """
@@ -47,7 +53,7 @@ from radicale import config, utils
INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only", INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only",
"from_file") "from_file")
INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMm" INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEe"
def load(configuration: "config.Configuration") -> "BaseRights": def load(configuration: "config.Configuration") -> "BaseRights":

View File

@@ -108,12 +108,16 @@ class BaseSharing:
self.permit_create_map = configuration.get("sharing", "permit_create_map") self.permit_create_map = configuration.get("sharing", "permit_create_map")
self.default_permissions_create_token = configuration.get("sharing", "default_permissions_create_token") self.default_permissions_create_token = configuration.get("sharing", "default_permissions_create_token")
self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map") self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map")
self.permit_properties_overlay = configuration.get("sharing", "permit_properties_overlay")
self.enforce_properties_overlay = configuration.get("sharing", "enforce_properties_overlay")
logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map)
logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token)
logger.info("sharing.permit_create_token: %s", self.permit_create_token) logger.info("sharing.permit_create_token: %s", self.permit_create_token)
logger.info("sharing.permit_create_map : %s", self.permit_create_map) logger.info("sharing.permit_create_map : %s", self.permit_create_map)
logger.info("sharing.default_permissions_create_token: %r", self.default_permissions_create_token) logger.info("sharing.default_permissions_create_token: %r", self.default_permissions_create_token)
logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map) logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map)
logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay)
logger.info("sharing.enforce_properties_overlay: %s", self.enforce_properties_overlay)
if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False)): if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False)):
logger.info("sharing disabled as no feature is enabled") logger.info("sharing disabled as no feature is enabled")
@@ -808,7 +812,16 @@ class BaseSharing:
if PathOrToken is None: if PathOrToken is None:
return httputils.bad_request("Missing PathOrToken") return httputils.bad_request("Missing PathOrToken")
if ShareType == "token": if ShareType not in ["token", "map"]:
logger.error(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type")
# check for permissions to update
share = self.get_sharing(ShareType=ShareType, PathOrToken=PathOrToken)
if share is None:
return httputils.NOT_FOUND
if share['Owner'] is not None and user == share['Owner']:
# unconditional update as owner
result = self.update_sharing( result = self.update_sharing(
ShareType=ShareType, ShareType=ShareType,
PathMapped=PathMapped, PathMapped=PathMapped,
@@ -816,27 +829,47 @@ class BaseSharing:
EnabledByOwner=EnabledByOwner, EnabledByOwner=EnabledByOwner,
HiddenByOwner=HiddenByOwner, HiddenByOwner=HiddenByOwner,
PathOrToken=str(PathOrToken), # verification above that it is not None PathOrToken=str(PathOrToken), # verification above that it is not None
OwnerOrUser=Owner, OwnerOrUser=user,
User=User, User=User,
Timestamp=Timestamp, Timestamp=Timestamp,
Properties=Properties) Properties=Properties)
elif ShareType == "map": elif share['User'] is not None and Owner == share['User']:
# User is only allowed to update Properties
if PathMapped is not None or EnabledByOwner is not None or HiddenByOwner is not None:
logger.info("Update sharing: access to %r not allowed for user %r to adjust anything beside Properties", PathOrToken, user)
return httputils.NOT_ALLOWED
if Properties is None:
logger.info("Update sharing: access to %r as user %r misses Properties", PathOrToken, user)
return httputils.NOT_ALLOWED
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/API/update: permit_properties_overlay=%s Permissions=%r", self.permit_properties_overlay, share['Permissions'])
if self.permit_properties_overlay:
if share['Permissions'] is not None and "p" in str(share['Permissions']):
logger.info("Update on shared %r: overlay permitted, but denied by permission 'p'", PathOrToken)
return httputils.NOT_ALLOWED
else:
logger.info("Update on shared %r: overlay permitted by option", PathOrToken)
else:
if share['Permissions'] is not None and "P" in str(share['Permissions']):
logger.info("Update on shared %r: overlay denied, but granted by permission 'P'", PathOrToken)
else:
logger.info("Update on shared %r: overlay denied by option", PathOrToken)
return httputils.NOT_ALLOWED
return httputils.NOT_ALLOWED
# limited update as user
result = self.update_sharing( result = self.update_sharing(
ShareType=ShareType, ShareType=ShareType,
PathMapped=PathMapped, PathMapped=PathMapped,
Permissions=Permissions,
EnabledByOwner=EnabledByOwner,
HiddenByOwner=HiddenByOwner,
PathOrToken=str(PathOrToken), # verification above that it is not None PathOrToken=str(PathOrToken), # verification above that it is not None
OwnerOrUser=Owner, OwnerOrUser=user,
User=User,
Timestamp=Timestamp, Timestamp=Timestamp,
Properties=Properties) Properties=Properties)
else: else:
logger.error(api_info + ": unsupported for ShareType=%r", ShareType) # neither owner nor user matches
return httputils.bad_request("Invalid share type") return httputils.NOT_ALLOWED
# result handling # result handling
if result['status'] == "not-found": if result['status'] == "not-found":

View File

@@ -135,6 +135,7 @@ class Sharing(sharing.BaseSharing):
Properties = row['Properties'] Properties = row['Properties']
return { return {
"mapped": True, "mapped": True,
"ShareType": ShareType,
"PathOrToken": PathOrToken, "PathOrToken": PathOrToken,
"PathMapped": PathMapped, "PathMapped": PathMapped,
"Owner": Owner, "Owner": Owner,
@@ -197,7 +198,7 @@ class Sharing(sharing.BaseSharing):
pass pass
else: else:
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/list/row: add: %r", row) logger.debug("TRACE/sharing/list/row: add : %r", row)
result.append(row) result.append(row)
index += 1 index += 1
return result return result
@@ -316,7 +317,7 @@ class Sharing(sharing.BaseSharing):
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/update: OwnerOrUser=%r PathOrToken=%r index=%d", ShareType, OwnerOrUser, PathOrToken, index) logger.debug("TRACE/sharing/%s/update: OwnerOrUser=%r PathOrToken=%r index=%d", ShareType, OwnerOrUser, PathOrToken, index)
logger.debug("TRACE/sharing/%s/update: orig row=%r", ShareType, row) logger.debug("TRACE/sharing/%s/update: orig row[%d]=%r", ShareType, index, row)
# CSV: remove+adjust+readd # CSV: remove+adjust+readd
if PathMapped is not None: if PathMapped is not None:
@@ -335,11 +336,10 @@ class Sharing(sharing.BaseSharing):
row["TimestampUpdated"] = Timestamp row["TimestampUpdated"] = Timestamp
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/%s/update: adj row=%r", ShareType, row) logger.debug("TRACE/sharing/%s/update: adj row[%d]=%r", ShareType, index, row)
# replace row # replace row
self._sharing_cache.pop(index) self._sharing_cache[index] = row
self._sharing_cache.append(row)
with self._storage.acquire_lock("w", OwnerOrUser, path=self._sharing_db_file): with self._storage.acquire_lock("w", OwnerOrUser, path=self._sharing_db_file):
if self._write_csv(self._sharing_db_file): if self._write_csv(self._sharing_db_file):
@@ -484,10 +484,8 @@ class Sharing(sharing.BaseSharing):
row['TimestampUpdated'] = Timestamp row['TimestampUpdated'] = Timestamp
# remove # replace
self._sharing_cache.pop(index) self._sharing_cache[index] = row
# readd
self._sharing_cache.append(row)
with self._storage.acquire_lock("w", OwnerOrUser, path=self._sharing_db_file): with self._storage.acquire_lock("w", OwnerOrUser, path=self._sharing_db_file):
if self._write_csv(self._sharing_db_file): if self._write_csv(self._sharing_db_file):
@@ -509,6 +507,7 @@ class Sharing(sharing.BaseSharing):
def _load_csv(self, file: str) -> bool: def _load_csv(self, file: str) -> bool:
logger.debug("sharing database load begin: %r", file) logger.debug("sharing database load begin: %r", file)
self._sharing_cache = []
with self._storage.acquire_lock("r", None): with self._storage.acquire_lock("r", None):
with open(file, 'r', newline='') as csvfile: with open(file, 'r', newline='') as csvfile:
reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1, delimiter=';') reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1, delimiter=';')
@@ -522,7 +521,7 @@ class Sharing(sharing.BaseSharing):
if fieldname not in row: if fieldname not in row:
logger.debug("sharing database is incompatible: %r", file) logger.debug("sharing database is incompatible: %r", file)
return False return False
# convert txt to bool # convert txt to bool or int
if self._lines > 0: if self._lines > 0:
for fieldname in sharing.DB_FIELDS_V1_BOOL: for fieldname in sharing.DB_FIELDS_V1_BOOL:
try: try:

View File

@@ -129,6 +129,7 @@ class Sharing(sharing.BaseSharing):
logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s Properties=%r)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden, Properties) logger.debug("TRACE/sharing: map %r to %r (Owner=%r User=%r Permissions=%r Hidden=%s Properties=%r)", PathOrToken, PathMapped, Owner, UserShare, Permissions, Hidden, Properties)
return { return {
"mapped": True, "mapped": True,
"ShareType": ShareType,
"PathOrToken": PathOrToken, "PathOrToken": PathOrToken,
"PathMapped": PathMapped, "PathMapped": PathMapped,
"Owner": Owner, "Owner": Owner,

View File

@@ -70,7 +70,61 @@ class TestSharingApiSanity(BaseTest):
_, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept) _, headers, answer = self._sharing_api(sharing_type, action, check, login, data, content_type, accept)
return _, headers, answer return _, headers, answer
def _propfind_calendar_color(self, path, login):
propfind_calendar_color = get_file_content("propfind_calendar_color.xml")
_, responses = self.propfind(path=path, data=propfind_calendar_color, login=login)
logging.info("response: %r", responses)
response = responses[path]
assert not isinstance(response, int)
status, prop = response["ICAL:calendar-color"]
logging.debug("calendar-color: %r", prop.text)
assert status == 200
return prop.text
def _proppatch_calendar_color(self, path, login, color) -> None:
_, responses = self.proppatch(path=path, data="""\
<?xml version="1.0" encoding="utf-8"?>
<D:propertyupdate xmlns:D="DAV:">
<D:set>
<D:prop>
<I:calendar-color xmlns:I="http://apple.com/ns/ical/">""" + color + """</I:calendar-color>
</D:prop>
</D:set>
</D:propertyupdate>""", login=login)
logging.info("response: %r", responses)
response = responses[path]
assert not isinstance(response, int) and len(response) == 1
status, prop = response["ICAL:calendar-color"]
assert status == 200 and not prop.text
return
def _proppatch_calendar_color_remove(self, path, login) -> None:
_, responses = self.proppatch(path=path, data="""\
<?xml version="1.0" encoding="utf-8"?>
<D:propertyupdate xmlns:D="DAV:">
<D:remove>
<D:prop>
<I:calendar-color xmlns:I="http://apple.com/ns/ical/" />
</D:prop>
</D:remove>
</D:propertyupdate>""", login=login)
logging.info("response: %r", responses)
response = responses[path]
assert not isinstance(response, int) and len(response) == 1
status, prop = response["ICAL:calendar-color"]
assert status == 200 and not prop.text
return
# Test functions # Test functions
def test_sharing_api_base_csv_custom(self) -> None:
self.database_path = os.path.join(self.colpath, "collection-db/test.csv")
self.configure({"sharing": {
"type": "csv",
"database_path": self.database_path,
"collection_by_map": "True",
"collection_by_token": "False"}
})
def test_sharing_api_base_no_auth(self) -> None: def test_sharing_api_base_no_auth(self) -> None:
"""POST request at '/.sharing' without authentication.""" """POST request at '/.sharing' without authentication."""
# disabled # disabled
@@ -2495,7 +2549,7 @@ permissions: RrWw""")
assert answer_dict['Lines'] == 1 assert answer_dict['Lines'] == 1
assert answer_dict['Content'][0]['Permissions'] == "RrWw" assert answer_dict['Content'][0]['Permissions'] == "RrWw"
def test_sharing_api_map_propfind_overlay(self) -> None: def test_sharing_api_map_propfind_overlay_api_base(self) -> None:
"""share-by-map API usage tests related to proppatch.""" """share-by-map API usage tests related to proppatch."""
self.configure({"auth": {"type": "htpasswd", self.configure({"auth": {"type": "htpasswd",
"htpasswd_filename": self.htpasswd_file_path, "htpasswd_filename": self.htpasswd_file_path,
@@ -2504,6 +2558,7 @@ permissions: RrWw""")
"type": "csv", "type": "csv",
"permit_create_map": True, "permit_create_map": True,
"permit_create_token": True, "permit_create_token": True,
"permit_properties_overlay": True,
"collection_by_map": "True", "collection_by_map": "True",
"collection_by_token": "True"}, "collection_by_token": "True"},
"logging": {"request_header_on_debug": "False", "logging": {"request_header_on_debug": "False",
@@ -2690,3 +2745,404 @@ permissions: RrWw""")
form_array.append("PathOrToken=" + path_shared_r) form_array.append("PathOrToken=" + path_shared_r)
form_array.append("Properties=BUGGYENTRY=BUGGYVALUE") form_array.append("Properties=BUGGYENTRY=BUGGYVALUE")
_, headers, answer = self._sharing_api_form("map", "update", check=400, login="user:userpw", form_array=form_array) _, headers, answer = self._sharing_api_form("map", "update", check=400, login="user:userpw", form_array=form_array)
def test_sharing_api_map_propfind_overlay_api_permissions(self) -> None:
"""share-by-map API usage tests related to proppatch."""
self.configure({"auth": {"type": "htpasswd",
"htpasswd_filename": self.htpasswd_file_path,
"htpasswd_encryption": "plain"},
"sharing": {
"type": "csv",
"permit_create_map": True,
"permit_create_token": True,
"collection_by_map": "True",
"collection_by_token": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_on_debug": "True",
"request_content_on_debug": "True"},
"rights": {"type": "owner_only"}})
json_dict: dict
path_mapped = "/owner/calendarPFOAP.ics/"
path_shared_r = "/user/calendarPFOAP-shared-by-owner-r.ics/"
logging.info("\n*** prepare and test access")
self.mkcalendar(path_mapped, login="owner:ownerpw")
for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)):
logging.info("\n*** test: %s", db_type)
self.configure({"sharing": {"type": db_type}})
# check PROPFIND as owner
logging.info("\n*** PROPFIND collection owner -> ok")
_, responses = self.propfind(path_mapped, """\
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<current-user-principal />
</prop>
</propfind>""", login="owner:ownerpw")
logging.info("response: %r", responses)
response = responses[path_mapped]
assert not isinstance(response, int) and len(response) == 1
status, prop = response["D:current-user-principal"]
assert status == 200 and len(prop) == 1
element = prop.find(xmlutils.make_clark("D:href"))
assert element is not None and element.text == "/owner/"
# execute PROPPATCH as owner
logging.info("\n*** PROPPATCH collection owner -> ok")
self._proppatch_calendar_color(path_mapped, login="owner:ownerpw", color="#AAAAAA")
# verify PROPPATCH by owner
logging.info("\n*** PROPFIND collection owner (verify collection change) -> ok")
color = self._propfind_calendar_color(path_mapped, login="owner:ownerpw")
assert color == "#AAAAAA"
# create map
logging.info("\n*** create map user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "r"
json_dict['Enabled'] = "True"
json_dict['Hidden'] = "False"
_, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
logging.info("\n*** list (json->json) db=" + db_type)
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
assert answer_dict['Lines'] == 1
assert answer_dict['Content'][0]['Permissions'] == "r"
# enable map by user
logging.info("\n*** enable map by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
logging.info("\n*** list after enable (json->json) db=" + db_type)
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
assert answer_dict['Lines'] == 1
assert answer_dict['Content'][0]['Permissions'] == "r"
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay, color back to owner) -> ok")
color = self._propfind_calendar_color(path_shared_r, login="user:userpw")
assert color == "#AAAAAA"
self.configure({"sharing": {"permit_properties_overlay": False}})
# update map by user
logging.info("\n*** update map by user (json) -> 403 (no overlay permitted)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathOrToken'] = path_shared_r
json_dict['Properties'] = {"ICAL:calendar-color": "#BBBBBB"}
_, headers, answer = self._sharing_api_json("map", "update", check=403, login="user:userpw", json_dict=json_dict)
self.configure({"sharing": {"permit_properties_overlay": True}})
logging.info("\n*** update map by user (json) -> 200 (overlay permitted)")
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="user:userpw", json_dict=json_dict)
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay) -> ok")
color = self._propfind_calendar_color(path_shared_r, login="user:userpw")
assert color == "#BBBBBB"
# update map by owner
logging.info("\n*** update map by owner (disable property overlay)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "rp"
json_dict['User'] = "user"
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
logging.info("\n*** list after update by owner (json->json) db=" + db_type)
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
assert answer_dict['Lines'] == 1
assert answer_dict['Content'][0]['Permissions'] == "rp"
logging.info("\n*** update map by user (json) -> 403 (no overlay permitted by share permissions)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathOrToken'] = path_shared_r
json_dict['Properties'] = {"ICAL:calendar-color": "#CCCCCC"}
_, headers, answer = self._sharing_api_json("map", "update", check=403, login="user:userpw", json_dict=json_dict)
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay) -> ok")
color = self._propfind_calendar_color(path_shared_r, login="user:userpw")
assert color == "#BBBBBB"
# update map by owner
logging.info("\n*** update map by owner (disable property overlay)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "rP"
json_dict['User'] = "user"
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
logging.info("\n*** update map by user (json) -> 200 (overlay permitted by share permissions)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathOrToken'] = path_shared_r
json_dict['Properties'] = {"ICAL:calendar-color": "#CCCCCC"}
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="user:userpw", json_dict=json_dict)
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay) -> ok")
color = self._propfind_calendar_color(path_shared_r, login="user:userpw")
assert color == "#CCCCCC"
self.configure({"sharing": {"permit_properties_overlay": True}})
# update map by owner
logging.info("\n*** update map by owner (disable property overlay)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "rp"
json_dict['User'] = "user"
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
logging.info("\n*** update map by user (json) -> 403 (overlay permitted but denied by share permissions)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathOrToken'] = path_shared_r
json_dict['Properties'] = {"ICAL:calendar-color": "#EEEEEE"}
_, headers, answer = self._sharing_api_json("map", "update", check=403, login="user:userpw", json_dict=json_dict)
def test_sharing_api_map_propfind_overlay_proppatch(self) -> None:
"""share-by-map API usage tests related to proppatch."""
self.configure({"auth": {"type": "htpasswd",
"htpasswd_filename": self.htpasswd_file_path,
"htpasswd_encryption": "plain"},
"sharing": {
"type": "csv",
"permit_create_map": True,
"permit_create_token": True,
"collection_by_map": "True",
"collection_by_token": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_on_debug": "True",
"request_content_on_debug": "True"},
"rights": {"type": "owner_only"}})
json_dict: dict
logging.info("\n*** prepare and test access")
for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)):
logging.info("\n*** test: %s", db_type)
self.configure({"sharing": {"type": db_type}})
path_mapped = "/owner/calendarPFP-" + db_type + ".ics/"
path_shared_r = "/user/calendarPFP-shared-by-owner-r-" + db_type + ".ics/"
self.mkcalendar(path_mapped, login="owner:ownerpw")
# check PROPFIND as owner
logging.info("\n*** PROPFIND collection owner -> ok")
_, responses = self.propfind(path_mapped, """\
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop>
<current-user-principal />
</prop>
</propfind>""", login="owner:ownerpw")
logging.info("response: %r", responses)
response = responses[path_mapped]
assert not isinstance(response, int) and len(response) == 1
status, prop = response["D:current-user-principal"]
assert status == 200 and len(prop) == 1
element = prop.find(xmlutils.make_clark("D:href"))
assert element is not None and element.text == "/owner/"
# execute PROPPATCH as owner
logging.info("\n*** PROPPATCH collection owner -> ok")
self._proppatch_calendar_color(path_mapped, login="owner:ownerpw", color="#AAAAAA")
# verify PROPPATCH by owner
logging.info("\n*** PROPFIND collection owner (verify collection change) -> ok")
color = self._propfind_calendar_color(path_mapped, login="owner:ownerpw")
assert color == "#AAAAAA"
# create map
logging.info("\n*** create map user/owner:rP -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "rP"
json_dict['Enabled'] = "True"
json_dict['Hidden'] = "False"
_, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
# enable map by user
logging.info("\n*** enable map by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
# verify PROPPATCH as user
logging.info("\n*** PROPFIND collection user -> ok")
color = self._propfind_calendar_color(path_shared_r, login="user:userpw")
assert color == "#AAAAAA"
# execute PROPPATCH as user
logging.info("\n*** PROPPATCH collection user -> ok")
self._proppatch_calendar_color(path_shared_r, login="user:userpw", color="#BBBBBB")
logging.info("\n*** list (json->json)")
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
assert answer_dict['Lines'] == 1
logging.info("\n*** list (json->csv)")
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/csv")
logging.info("\n*** list (json->txt)")
json_dict['PathOrToken'] = path_shared_r
_, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict, accept="text/plain")
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay) -> ok")
color = self._propfind_calendar_color(path_shared_r, login="user:userpw")
assert color == "#BBBBBB"
# verify overlay not visible by owner
logging.info("\n*** PROPFIND collection owner (no collection change) -> ok")
color = self._propfind_calendar_color(path_mapped, login="owner:ownerpw")
assert color == "#AAAAAA"
# execute PROPPATCH as user (delete color)
logging.info("\n*** PROPPATCH collection user (delete color) -> ok")
self._proppatch_calendar_color_remove(path_shared_r, login="user:userpw")
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay, color back to owner) -> ok")
color = self._propfind_calendar_color(path_shared_r, login="user:userpw")
assert color == "#AAAAAA"
# update map by owner
logging.info("\n*** update map by owner (disable property overlay)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "rwe"
json_dict['User'] = "user"
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
# execute PROPPATCH as user
logging.info("\n*** PROPPATCH collection user (set color/collection) -> ok")
self._proppatch_calendar_color(path_shared_r, login="user:userpw", color="#DDDDDD")
# verify overlay as user
logging.info("\n*** PROPFIND collection user (collection color changed) -> ok")
color = self._propfind_calendar_color(path_shared_r, login="user:userpw")
assert color == "#DDDDDD"
# verify overlay visible by owner
logging.info("\n*** PROPFIND collection owner (visible change by user) -> ok")
color = self._propfind_calendar_color(path_mapped, login="owner:ownerpw")
assert color == "#DDDDDD"
# update map by owner
logging.info("\n*** update map by owner (enable property overlay)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "rwE"
json_dict['User'] = "user"
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
# execute PROPPATCH as user
logging.info("\n*** PROPPATCH collection user (set color rw, enforce overlay enabled by default) -> ok")
self._proppatch_calendar_color(path_shared_r, login="user:userpw", color="#EEEEEE")
# verify overlay visible by owner
logging.info("\n*** PROPFIND collection owner (invisible change by user) -> ok")
color = self._propfind_calendar_color(path_mapped, login="owner:ownerpw")
assert color == "#DDDDDD"
# verify overlay as user
logging.info("\n*** PROPFIND collection user (overlay: color) -> ok")
color = self._propfind_calendar_color(path_shared_r, login="user:userpw")
assert color == "#EEEEEE"
# update map by owner
logging.info("\n*** update map by owner (enable property overlay)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "rwe"
json_dict['User'] = "user"
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
# execute PROPPATCH as user
logging.info("\n*** PROPPATCH collection user (set color rwe, enforce overlay enabled by default) -> ok")
self._proppatch_calendar_color(path_shared_r, login="user:userpw", color="#EEEE00")
# verify overlay visible by owner
logging.info("\n*** PROPFIND collection owner (visible change) -> ok")
color = self._propfind_calendar_color(path_mapped, login="owner:ownerpw")
assert color == "#EEEE00"
self.configure({"sharing": {"enforce_properties_overlay": False}})
# update map by owner
logging.info("\n*** update map by owner (enable property overlay)")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "rw"
json_dict['User'] = "user"
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
logging.info("\n*** PROPPATCH collection user (set color rwe but enforce disabled) -> ok")
self._proppatch_calendar_color(path_shared_r, login="user:userpw", color="#FFFFFF")
# verify visible by owner
logging.info("\n*** PROPFIND collection owner (visible change) -> ok")
color = self._propfind_calendar_color(path_mapped, login="owner:ownerpw")
assert color == "#FFFFFF"