From edd20d34f2c9fd39dad22aeba62925693c919265 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 18 Jul 2026 15:02:31 +0200 Subject: [PATCH 01/26] group: add general support and for htgroup file --- radicale/config.py | 19 +++- radicale/group/__init__.py | 71 +++++++++++++++ radicale/group/htgroup.py | 182 +++++++++++++++++++++++++++++++++++++ radicale/group/none.py | 30 ++++++ 4 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 radicale/group/__init__.py create mode 100644 radicale/group/htgroup.py create mode 100644 radicale/group/none.py diff --git a/radicale/config.py b/radicale/config.py index ea94c2d5..efddda3e 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -39,8 +39,8 @@ from configparser import RawConfigParser from typing import (Any, Callable, ClassVar, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union) -from radicale import (auth, hook, log, rights, sharing, storage, types, utils, - web) +from radicale import (auth, group, hook, log, rights, sharing, storage, types, + utils, web) from radicale.hook import email from radicale.item import check_and_sanitize_props @@ -484,6 +484,21 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "False", "help": "url-decode the username, set to True when clients send url-encoded email address as username", "type": bool})])), + ("group", OrderedDict([ + ("type", { + "value": "none", + "help": "group lookup method (" + "|".join(group.INTERNAL_TYPES) + ")", + "type": str_or_callable, + "internal": group.INTERNAL_TYPES}), + ("htgroup_filename", { + "value": "/etc/radicale/groups", + "help": "htpgroup filename", + "type": filepath}), + ("htgroup_cache", { + "value": "False", + "help": "enable caching of htgroup file", + "type": bool}), + ])), ("rights", OrderedDict([ ("type", { "value": "owner_only", diff --git a/radicale/group/__init__.py b/radicale/group/__init__.py new file mode 100644 index 00000000..4cd8729d --- /dev/null +++ b/radicale/group/__init__.py @@ -0,0 +1,71 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Group membership module. + +Enrich user with group membership + +Take a look at the class ``BaseGroup`` if you want to implement your own. + +""" + +from typing import Sequence, Set, final + +from radicale import config, utils +from radicale.log import logger + +INTERNAL_TYPES: Sequence[str] = ("none", + "htgroup", + ) + + +def load(configuration: "config.Configuration") -> "BaseGroup": + """Load the group module chosen in configuration.""" + _type = configuration.get("group", "type") + if _type == "none": + logger.info("No user groups lookup method is selected") + else: + logger.info("User groups lookup method: %r", _type) + return utils.load_plugin(INTERNAL_TYPES, "group", "Group", BaseGroup, + configuration) + + +class BaseGroup: + + def __init__(self, configuration: "config.Configuration") -> None: + """Initialize BaseGroup. + + ``configuration`` see ``radicale.config`` module. + The ``configuration`` must not change during the lifetime of + this object, it is kept as an internal reference. + + """ + self.configuration = configuration + self._type = configuration.get("group", "type") + + def _groups(self, login: str) -> Set[str]: + """Retrieve set of groups of a user + + ``login`` the login name + + """ + + raise NotImplementedError + + @final + def groups(self, login: str) -> Set[str]: + return self._groups(login) diff --git a/radicale/group/htgroup.py b/radicale/group/htgroup.py new file mode 100644 index 00000000..55aa0c4b --- /dev/null +++ b/radicale/group/htgroup.py @@ -0,0 +1,182 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Backend that retrieves groups of a user from htgroups file. + +Apache's htgroup format (https://httpd.apache.org/docs/2.4/mod/mod_authz_groupfile.html) +""" + +import os +import threading +import time +from typing import Set, Tuple + +from radicale import config, group, logger + + +class Group(group.BaseGroup): + + _filename: str + _encoding: str + _htgroup_by_member: dict[str, Set] # member -> groups (set) + _htgroup_mtime_ns: int + _htgroup_size: int + _htgroup_ok: bool + _htgroup_not_ok_time: float + _htgroup_not_ok_reminder_seconds: int + _htgroup_cache: bool + _lock: threading.Lock + + def __init__(self, configuration: config.Configuration) -> None: + super().__init__(configuration) + self._filename = configuration.get("group", "htgroup_filename") + logger.info("group htgroup file: %r", self._filename) + self._encoding = configuration.get("encoding", "stock") + logger.info("group htgroup file encoding: %r", self._encoding) + self._htgroup_cache = configuration.get("group", "htgroup_cache") + logger.info("group htgroup cache: %s", self._htgroup_cache) + + self._htgroup_ok = False + self._htgroup_not_ok_reminder_seconds = 60 # currently hardcoded + (self._htgroup_ok, self._htgroup_by_member, self._htgroup_size, self._htgroup_mtime_ns) = self._read_htgroup(True, False) + self._lock = threading.Lock() + + def _read_htgroup(self, init: bool, suppress: bool) -> Tuple[bool, dict, int, int]: + """Read htgroup file + + init == True: stop on error + init == False: warn/skip on error and set mark to log reminder every interval + suppress == True: suppress warnings, change info to debug (used in non-caching mode) + suppress == False: do not suppress warnings (used in caching mode) + + """ + htgroup_ok = True + if (init is True) or (suppress is True): + info = "Read" + else: + info = "Re-read" + if suppress is False: + logger.info("%s content of htgroup file start: %r", info, self._filename) + else: + logger.debug("%s content of htgroup file start: %r", info, self._filename) + htgroup: dict[str, str] = dict() + htgroup_by_member: dict[str, Set[str]] = dict() + entries = 0 + duplicates = 0 + errors = 0 + try: + with open(self._filename, encoding=self._encoding) as f: + line_num = 0 + for line in f: + line_num += 1 + line = line.rstrip("\n") + if line.lstrip() and not line.lstrip().startswith("#"): + try: + group, members = line.split(":", maxsplit=1) + skip = False + if group == "" or members == "": + if init is True: + raise ValueError("htgroup file contains problematic line not matching : in line: %d" % line_num) + else: + errors += 1 + logger.warning("htgroup file contains problematic line not matching : in line: %d (ignored)", line_num) + htgroup_ok = False + skip = True + else: + if htgroup.get(group): + duplicates += 1 + if init is True: + raise ValueError("htgroup file contains duplicate group: '%s'", group, line_num) + else: + logger.warning("htgroup file contains duplicate group: '%s' (line: %d / ignored)", group, line_num) + htgroup_ok = False + skip = True + if skip is False: + htgroup[group] = members + entries += 1 + except ValueError as e: + if init is True: + raise RuntimeError("Invalid htgroup file %r: %s" % (self._filename, e)) from e + except OSError as e: + if init is True: + raise RuntimeError("Failed to load htgroup file %r: %s" % (self._filename, e)) from e + else: + logger.warning("Failed to load htgroup file on re-read: %r" % self._filename) + htgroup_ok = False + htgroup_size = os.stat(self._filename).st_size + htgroup_mtime_ns = os.stat(self._filename).st_mtime_ns + if suppress is False: + logger.info("%s content of htgroup file done: %r (entries: %d, duplicates: %d, errors: %d)", info, self._filename, entries, duplicates, errors) + else: + logger.debug("%s content of htgroup file done: %r (entries: %d, duplicates: %d, errors: %d)", info, self._filename, entries, duplicates, errors) + if htgroup_ok is True: + self._htgroup_not_ok_time = 0 + else: + self._htgroup_not_ok_time = time.time() + # convert mapping + for group in htgroup: + for member in htgroup[group].split(' '): + if member not in htgroup_by_member: + htgroup_by_member[member] = set([group]) + else: + htgroup_by_member[member].add(group) + return (htgroup_ok, htgroup_by_member, htgroup_size, htgroup_mtime_ns) + + def _groups(self, login: str) -> Set[str]: + """Get list of groups of login + + Optional: the content of the file is cached and live updates will be detected by + comparing mtime_ns and size + """ + logger.trace("Group memberships (htgroup) lookup for user %r", login) + group_ok = False + groups: Set[str] + if self._htgroup_cache is True: + # check and re-read file if required + with self._lock: + htgroup_size = os.stat(self._filename).st_size + htgroup_mtime_ns = os.stat(self._filename).st_mtime_ns + if (htgroup_size != self._htgroup_size) or (htgroup_mtime_ns != self._htgroup_mtime_ns): + (self._htgroup_ok, self._htgroup, self._htgroup_size, self._htgroup_mtime_ns) = self._read_htgroup(False, False) + self._htgroup_not_ok_time = 0 + + # log reminder of problemantic file every interval + current_time = time.time() + if (self._htgroup_ok is False): + if (self._htgroup_not_ok_time > 0): + if (current_time - self._htgroup_not_ok_time) > self._htgroup_not_ok_reminder_seconds: + logger.warning("htgroup file still contains issues (REMINDER, check warnings in the past): %r" % self._filename) + self._htgroup_not_ok_time = current_time + else: + self._htgroup_not_ok_time = current_time + + if self._htgroup_by_member.get(login): + groups = self._htgroup_by_member[login] + group_ok = True + else: + # read file on every request + (htgroup_ok, htgroup_by_member, htgroup_size, htgroup_mtime_ns) = self._read_htgroup(False, True) + if htgroup_by_member.get(login): + groups = htgroup_by_member[login] + group_ok = True + + if group_ok is True: + logger.debug("Group memberships (htgroup) for user %r: %r", login, groups) + return groups + else: + logger.debug("Group memberships (htgroup) for user %r not found", login) + return set([]) diff --git a/radicale/group/none.py b/radicale/group/none.py new file mode 100644 index 00000000..e582a551 --- /dev/null +++ b/radicale/group/none.py @@ -0,0 +1,30 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +A dummy backend that returns no group. + +""" + +from typing import Set + +from radicale import group + + +class Group(group.BaseGroup): + + def _groups(self, login: str) -> Set[str]: + return set([]) From 84402d0f334175f3c7ba15813d0c6ab4b3ff0f7a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 18 Jul 2026 15:03:06 +0200 Subject: [PATCH 02/26] group: add doc --- DOCUMENTATION.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 66f0ac7b..d188b557 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1494,6 +1494,39 @@ This setting forces decoding the username. Default: `False` +#### [group] + +_(>= 3.8.0)_ + +##### type + +The method to lookup groups for username + +Available types are: + +* `none` + No groups lookup (exception: LDAP, see _auth_ section) + +* `htgroup` + Use an + [Apache htgroup file](https://httpd.apache.org/docs/2.4/mod/mod_authz_groupfile.html) + to store groups and their members + +##### htgroup_filename + +_(>= 3.8.0)_ + +Path to the htgroup file. + +Default: `/etc/radicale/groups` + +##### htgroup_cache + +_(>= 3.8.0)_ + +Enable caching of htgroup file based on size and mtime_ns + +Default: `False` #### [rights] From bfc841cd4d44d93cd8a77152bc99bed3678dce21 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 18 Jul 2026 15:03:20 +0200 Subject: [PATCH 03/26] group: add reference config --- config | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/config b/config index 8a8024c7..3b791f5d 100644 --- a/config +++ b/config @@ -211,6 +211,19 @@ #urldecode_username = False +[group] + +# Group lookup method +# Value: none | htgroup +type = none + +# Htgroup filename +#htgroup_filename = /etc/radicale/groups + +# Enable caching of htgroup file based on size and mtime_ns +#htgroup_cache = False + + [rights] # Rights backend From 4303f2a938109c2c10b45e60cb72d7ffcc29ea0d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 18 Jul 2026 15:04:21 +0200 Subject: [PATCH 04/26] group: enrich username check for url encoded ones, shift group retrievement --- radicale/app/__init__.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index 1796e24d..7643567e 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -74,6 +74,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, """WSGI application.""" _mask_passwords: bool + _urldecode_username: bool _auth_delay: float _delay_on_error: float _internal_server: bool @@ -110,6 +111,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, if not os.access(os.environ['TEMP'], os.W_OK): raise RuntimeError("TEMP found in environment, but not writable: %r" % os.environ['TEMP']) self._mask_passwords = configuration.get("logging", "mask_passwords") + self._urldecode_username = configuration.get("auth", "urldecode_username") self._delay_on_error = configuration.get("server", "delay_on_error") logger.info("delay_on_error set to: %.3f seconds", self._delay_on_error) self._max_content_length = configuration.get("server", "max_content_length") @@ -549,18 +551,11 @@ class Application(ApplicationPartDelete, ApplicationPartHead, self.configuration, environ, base64.b64decode( authorization.encode("ascii"))).split(":", 1) - if login and not app_base._check_user_format(self._storage, login, self._validate_user_value): + if login and not app_base._check_user_format(self._storage, login, self._validate_user_value, self._urldecode_username): info = "not compliant to %r" % self._validate_user_value user = "" else: (user, info) = self._auth.login(login, password, context) or ("", "") if login else ("", "") - if self.configuration.get("auth", "type") == "ldap": - try: - logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups)) - self._rights._user_groups = self._auth._ldap_groups - except AttributeError: - pass - request_info: dict = { "method": request_method, "login": login, # not 'user' in this step @@ -602,6 +597,16 @@ class Application(ApplicationPartDelete, ApplicationPartHead, logger.info("Refused unsafe username: %r", user) user = "" + if user: + if self.configuration.get("group", "type") != "none": + self._rights._user_groups = self._group.groups(login) if login else set([]) + elif self.configuration.get("auth", "type") == "ldap": + try: + logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups)) + self._rights._user_groups = self._auth._ldap_groups + except AttributeError: + pass + # Create principal collection if user: principal_path = "/%s/" % user From ffb95dc4eaf51b26e81a32fcfdb9d9383cc8608a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 18 Jul 2026 15:05:25 +0200 Subject: [PATCH 05/26] group: extend username check --- radicale/app/base.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/radicale/app/base.py b/radicale/app/base.py index f453f5cb..9912fde2 100644 --- a/radicale/app/base.py +++ b/radicale/app/base.py @@ -22,9 +22,10 @@ import sys import unicodedata import xml.etree.ElementTree as ET from typing import Optional, Union +from urllib.parse import unquote -from radicale import (auth, config, hook, httputils, log, pathutils, rights, - sharing, storage, types, utils, web, xmlutils) +from radicale import (auth, config, group, hook, httputils, log, pathutils, + rights, sharing, storage, types, utils, web, xmlutils) from radicale.log import logger from radicale.rights import intersect @@ -84,8 +85,30 @@ def _check_format(self: storage.BaseStorage, def _check_user_format(self: storage.BaseStorage, user: str, - validation_type: str + validation_type: str, + urldecode_username: bool, + enforceUser: bool = True, ) -> bool: + logger.trace("_check_user_format investigate %r (urldecode_username=%r)", user, urldecode_username) + if urldecode_username: + user = unquote(user) + if (user.startswith(sharing.SHARING_SEPARATOR_GROUP) or user.startswith(sharing.SHARING_SEPARATOR_REALM)): + if enforceUser: + # group/realm identifiers + return False + else: + # strip 1st char + user = user[1:] + if enforceUser: + if user.count(sharing.SHARING_SEPARATOR_GROUP) > 0: + # not allowed (avoid injecting a group) + return False + elif user.count(sharing.SHARING_SEPARATOR_REALM) > 1: + # only allowed once + return False + if (user.endswith(sharing.SHARING_SEPARATOR_GROUP) or user.endswith(sharing.SHARING_SEPARATOR_REALM)): + # group/realm identifiers + return False if validation_type == "strict": return (re.search(USER_PATTERN_STRICT_RE, user) is not None) else: @@ -116,6 +139,7 @@ class ApplicationBase: configuration: config.Configuration _auth: auth.BaseAuth + _group: group.BaseGroup _storage: storage.BaseStorage _rights: rights.BaseRights _web: web.BaseWeb @@ -133,6 +157,7 @@ class ApplicationBase: def __init__(self, configuration: config.Configuration) -> None: self.configuration = configuration self._auth = auth.load(configuration) + self._group = group.load(configuration) self._storage = storage.load(configuration) self._rights = rights.load(configuration) self._web = web.load(configuration) From 15cb7955cc4c61a2f535d53e28af3b97d07b4c49 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 18 Jul 2026 15:07:07 +0200 Subject: [PATCH 06/26] group: add support for propfind --- radicale/app/propfind.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 13d1f2fc..531dd52e 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -27,8 +27,8 @@ from http import client from typing import (Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, Union) -from radicale import (httputils, pathutils, rights, storage, types, utils, - xmlutils) +from radicale import (httputils, pathutils, rights, sharing, storage, types, + utils, xmlutils) from radicale.app.base import Access, ApplicationBase from radicale.log import logger @@ -639,7 +639,10 @@ class ApplicationPartPropfind(ApplicationBase): if http_depth == "1": logger.trace("PROPFIND: get shared collections") # check for shared collections related to user, Enabled and not Hidden - collections_share_list = self._sharing.sharing_collection_list(User=user, Enabled=True, Hidden=False) + user_lookup = user + if self._rights._user_groups is not None and len(self._rights._user_groups) > 0: + user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups) + collections_share_list = self._sharing.sharing_collection_list(User=user_lookup, Enabled=True, Hidden=False) if collections_share_list: for share in collections_share_list: c_share = share['PathOrToken'] From a06e10a1dd593e425834822c6110c83dbc0a1d23 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 18 Jul 2026 15:08:02 +0200 Subject: [PATCH 07/26] group: add sharing support --- radicale/sharing/__init__.py | 45 +++++++++-- radicale/sharing/csv.py | 144 ++++++++++++++++++++--------------- radicale/sharing/files.py | 128 +++++++++++++++++++++++-------- 3 files changed, 222 insertions(+), 95 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 88874d31..f5d3680d 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -131,6 +131,9 @@ SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT: str = "BDAY={year}-{month}-{day}" SHARING_BDAY_CATEGORIES_DEFAULT: str = 'Birthday' SHARING_ACTIONS_DELETE_VALUE: str = '#DEL#' +SHARING_SEPARATOR_REALM: str = '@' +SHARING_SEPARATOR_GROUP: str = ':' + def check_bday_max_age(data: Any) -> int: value = int(data) @@ -915,7 +918,7 @@ class BaseSharing: elif not request_data[key].endswith("/"): return httputils.bad_request("PathMapped not ending with /") elif key == "User": - if not app_base._check_user_format(self._storage, request_data[key], self._validate_user_value): + if not app_base._check_user_format(self._storage, request_data[key], self._validate_user_value, enforceUser=False, urldecode_username=False): logger.warning("%s: invalid %r: %r (not compliant to %r)", api_info, key, request_data[key], self._validate_user_value) return httputils.bad_request("Invalid value for User") @@ -1183,10 +1186,18 @@ class BaseSharing: logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user) return httputils.NOT_ALLOWED - access = Access(self._rights, User, PathOrToken) - if not access.check("r"): - logger.warning(api_info + ": access to PathOrToken=%r not allowed for User=%r", PathOrToken, User) - return httputils.NOT_ALLOWED + if User.startswith(SHARING_SEPARATOR_GROUP) or User.startswith(SHARING_SEPARATOR_REALM): + if PathOrToken.startswith("/{user}/"): + # placeholder exists + pass + else: + logger.warning(api_info + ": PathOrToken=%r has to start with placeholder for 'user' using group User=%r", PathOrToken, User) + return httputils.NOT_ALLOWED + else: + access = Access(self._rights, User, PathOrToken) + if not access.check("r"): + logger.warning(api_info + ": access to PathOrToken=%r not allowed for User=%r", PathOrToken, User) + return httputils.NOT_ALLOWED # check whether share is already existing as real collection with self._storage.acquire_lock("r", User, path=PathOrToken): @@ -1197,6 +1208,22 @@ class BaseSharing: logger.warning(api_info + ": PathOrToken=%r already exists as real collection for User=%r", PathOrToken, User) return httputils.CONFLICT + if User.startswith(SHARING_SEPARATOR_GROUP) or User.startswith(SHARING_SEPARATOR_REALM): + # enforce user toggles for groups + HiddenByUser = False + EnabledByUser = True + if "E" in Permissions: + logger.warning(api_info + ": 'E' in Permissions=%r not allowed for group User=%r", Permissions, User) + return httputils.NOT_ALLOWED + elif "P" in Permissions: + logger.warning(api_info + ": 'P' in Permissions=%r not allowed for group User=%r", Permissions, User) + return httputils.NOT_ALLOWED + # enforce permissions for group + if "e" not in Permissions: + Permissions += "e" + if "p" not in Permissions: + Permissions += "p" + logger.trace("" + api_info + ": %r (Permissions=%r PathOrToken=%r Owner=%r User=%r)", PathMapped, Permissions, PathOrToken, user, User) result = self.database_create_sharing( @@ -1318,6 +1345,14 @@ class BaseSharing: logger.warning(api_info + ": PathMapped=%r change of Conversion %r -> %r is not supported", PathMapped, share['Conversion'], Conversion) return httputils.bad_request("Change of conversion is not supported") + if User is not None and (User.startswith('!') or User.startswith('@')): + # enforce user permissions for groups + if Permissions is not None: + if "e" not in Permissions: + Permissions += "e" + if "p" not in Permissions: + Permissions += "p" + if user == share['Owner']: if PathMapped is not None: # check access Permissions diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 03d59ce1..14d9adee 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -95,37 +95,21 @@ class Sharing(sharing.BaseSharing): OnlyEnabled: bool = True, User: Union[str, None] = None) -> Union[dict, None]: """ retrieve sharing target and attributes by map """ - # Lookup - logger.trace("sharing: lookup ShareType=%r PathOrToken=%r User=%r OnlyEnabled=%s)", ShareType, PathOrToken, User, OnlyEnabled) + logger.trace("sharing/%s/get: PathOrToken=%r User=%r OnlyEnabled=%s", ShareType, PathOrToken, User, OnlyEnabled) - index = 0 found = False - for row in self._sharing_cache: - if index == 0: - # skip fieldnames - pass + for row in self.database_list_sharing(ShareType=ShareType, PathOrToken=PathOrToken, User=User): + # run through prefiltered list + logger.trace("sharing/get/check: %r", row) + if OnlyEnabled is True and row['EnabledByOwner'] is False: + continue + elif OnlyEnabled is True and row['EnabledByUser'] is False: + continue else: - logger.trace("sharing: check row: %r", row) - if row['ShareType'] != ShareType: - pass - elif row['PathOrToken'] != PathOrToken: - pass - elif User is not None and row['User'] != User: - pass - elif OnlyEnabled is True and row['EnabledByOwner'] is not True: - pass - elif OnlyEnabled is True and row['EnabledByUser'] is not True: - pass - else: - found = True - break - index += 1 + found = True + break if found: - PathMapped = row['PathMapped'] - Owner = row['Owner'] - UserShare = row['User'] - Permissions = row['Permissions'] Hidden: bool = (row['HiddenByOwner'] or row['HiddenByUser']) Properties: Union[dict, None] = None Conversion: Union[str, None] = None @@ -140,13 +124,13 @@ class Sharing(sharing.BaseSharing): "mapped": True, "ShareType": ShareType, "PathOrToken": PathOrToken, - "PathMapped": PathMapped, - "Owner": Owner, - "User": UserShare, + "PathMapped": row['PathMapped'], + "Owner": row['Owner'], + "User": row['User'], "Hidden": Hidden, "EnabledByOwner": row['EnabledByOwner'], "EnabledByUser": row['EnabledByUser'], - "Permissions": Permissions, + "Permissions": row['Permissions'], "Properties": Properties, "Conversion": Conversion, "Actions": Actions, @@ -174,39 +158,79 @@ class Sharing(sharing.BaseSharing): logger.trace("sharing/list/called: ShareType=%r OwnerOrUser=%r User=%r PathOrToken=%r PathMapped=%r EnabledByOwner=%s EnabledByUser=%s HiddenByOwner=%s HiddenByUser=%s Conversion=%r", ShareType, OwnerOrUser, User, PathOrToken, PathMapped, EnabledByOwner, EnabledByUser, HiddenByOwner, HiddenByUser, Conversion) for row in self._sharing_cache: - if index == 0: + index += 1 + if index == 1: # skip fieldnames - pass - else: - logger.trace("sharing/list/row: test: %r", row) - if ShareType is not None and row['ShareType'] != ShareType: - logger.trace("sharing/list/row: skip by ShareType") - pass - elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser): - pass - elif User is not None and row['User'] != User: - logger.trace("sharing/list/row: skip by User") - pass - elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: - logger.trace("sharing/list/row: skip by PathOrToken") - pass - elif PathMapped is not None and row['PathMapped'] != PathMapped: - logger.trace("sharing/list/row: skip by PathMapped") - pass - elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: - pass - elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: - pass - elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: - pass - elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: - pass - elif Conversion is not None and row['Conversion'] != Conversion: + continue + + logger.trace("sharing/list/row: test: %r", row) + + if ShareType is not None and row['ShareType'] != ShareType: + continue + if Conversion is not None and row['Conversion'] != Conversion: + continue + if EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: + continue + if EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: + continue + if HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: + continue + if HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: + continue + if PathMapped is not None and row['PathMapped'] != PathMapped: + continue + if OwnerOrUser is not None: + if User is not None and OwnerOrUser == User: + pass # will be checked below + elif (row['Owner'] != OwnerOrUser) and (row['User'] != OwnerOrUser): + continue + + group_check = False + if row['User'].startswith(sharing.SHARING_SEPARATOR_GROUP) or row['User'].startswith(sharing.SHARING_SEPARATOR_REALM): + group_check = True + + if User is not None: + if row['User'].startswith(sharing.SHARING_SEPARATOR_REALM): + if not User.endswith(row['User']): + continue + else: + pass + elif row['User'].startswith(sharing.SHARING_SEPARATOR_GROUP): + if sharing.SHARING_SEPARATOR_GROUP not in User: + continue # user has no group + user_without_group = User.split(sharing.SHARING_SEPARATOR_GROUP)[0] + groups_of_user = User.split(sharing.SHARING_SEPARATOR_GROUP)[1].split(',') + Groups = row['User'].removeprefix(sharing.SHARING_SEPARATOR_GROUP).split(',') + logger.trace("sharing/list/check/groups: groups_of_user=%r Groups=%r", groups_of_user, Groups) + found = False + for group in groups_of_user: + if group in Groups: + found = True + break + if found: + pass + else: + continue + elif row['User'] == User: pass else: - logger.trace("sharing/list/row: add : %r", row) - result.append(row) - index += 1 + continue + + row_copy = row.copy() + + if group_check and User is not None: + if row['User'].startswith(sharing.SHARING_SEPARATOR_GROUP): + user_without_group = User.split(sharing.SHARING_SEPARATOR_GROUP)[0] + else: + user_without_group = User + row_copy['PathOrToken'] = row['PathOrToken'].replace("{user}", user_without_group) # replace placeholder + row_copy['User'] = user_without_group # replace with real user + + if PathOrToken is not None and row_copy['PathOrToken'] != PathOrToken: + continue + + logger.trace("sharing/list/row: add : %r", row_copy) + result.append(row_copy) return result def database_create_sharing(self, diff --git a/radicale/sharing/files.py b/radicale/sharing/files.py index 1e6fe843..1d56fbe3 100644 --- a/radicale/sharing/files.py +++ b/radicale/sharing/files.py @@ -91,13 +91,37 @@ class Sharing(sharing.BaseSharing): OnlyEnabled: bool = True, User: Union[str, None] = None) -> Union[dict, None]: """ retrieve sharing target and attributes by map """ - # Lookup - logger.trace("sharing/%s/get: PathOrToken=%r User=%r)", ShareType, PathOrToken, User) - sharing_config_file = os.path.join(self._sharing_database_path_ShareType[ShareType], self._encode_path(PathOrToken)) + logger.trace("sharing/%s/get: PathOrToken=%r User=%r OnlyEnabled=%s -> config=%r)", ShareType, PathOrToken, User, OnlyEnabled, sharing_config_file) + if not os.path.isfile(sharing_config_file): - return None + if ShareType != "map" or User is None: + return None + else: + # check by group + logger.trace("sharing/%s/get: no direct share found, run through filtered list") + for row in self.database_list_sharing(ShareType=ShareType, PathOrToken=PathOrToken, User=User): + if OnlyEnabled is True and row['EnabledByOwner'] is False: + continue + if OnlyEnabled is True and row['EnabledByUser'] is False: + continue + return { + "mapped": True, + "ShareType": ShareType, + "PathOrToken": row['PathOrToken'], + "PathMapped": row['PathMapped'], + "Owner": row['Owner'], + "User": row['User'], + "Hidden": row['HiddenByOwner'], + "EnabledByOwner": row['EnabledByOwner'], + "EnabledByUser": row['EnabledByUser'], + "Permissions": row['Permissions'], + "Properties": row['Properties'], + "Conversion": row['Conversion'], + "Actions": row['Actions'], + } + return None # read content with self._storage.acquire_lock("r", User): @@ -187,33 +211,77 @@ class Sharing(sharing.BaseSharing): continue logger.trace("sharing/list/row: test: %r", row) + if ShareType is not None and row['ShareType'] != ShareType: - logger.trace("sharing/list/row: skip by ShareType") - pass - elif OwnerOrUser is not None and (row['Owner'] != OwnerOrUser and row['User'] != OwnerOrUser): - pass - elif User is not None and row['User'] != User: - logger.trace("sharing/list/row: skip by User") - pass - elif PathOrToken is not None and row['PathOrToken'] != PathOrToken: - logger.trace("sharing/list/row: skip by PathOrToken") - pass - elif PathMapped is not None and row['PathMapped'] != PathMapped: - logger.trace("sharing/list/row: skip by PathMapped") - pass - elif EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: - pass - elif EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: - pass - elif HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: - pass - elif HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: - pass - elif Conversion is not None and row['Conversion'] != Conversion: - pass - else: - logger.trace("sharing/list/row: add: %r", row) - result.append(row) + continue + if Conversion is not None and row['Conversion'] != Conversion: + continue + if EnabledByOwner is not None and row['EnabledByOwner'] != EnabledByOwner: + continue + if EnabledByUser is not None and row['EnabledByUser'] != EnabledByUser: + continue + if HiddenByOwner is not None and row['HiddenByOwner'] != HiddenByOwner: + continue + if HiddenByUser is not None and row['HiddenByUser'] != HiddenByUser: + continue + if PathMapped is not None and row['PathMapped'] != PathMapped: + continue + if OwnerOrUser is not None: + if User is not None and OwnerOrUser == User: + pass # will be checked below + elif (row['Owner'] != OwnerOrUser) and (row['User'] != OwnerOrUser): + continue + + group_check = False + if row['User'].startswith(sharing.SHARING_SEPARATOR_GROUP) or row['User'].startswith(sharing.SHARING_SEPARATOR_REALM): + group_check = True + + if User is not None: + if row['User'].startswith(sharing.SHARING_SEPARATOR_REALM): + if not User.endswith(row['User']): + continue + elif row['User'].startswith(sharing.SHARING_SEPARATOR_GROUP): + if sharing.SHARING_SEPARATOR_GROUP not in User: + continue # user has no group + user_without_group = User.split(sharing.SHARING_SEPARATOR_GROUP)[0] + groups_of_user = User.split(sharing.SHARING_SEPARATOR_GROUP)[1].split(',') + Groups = row['User'].removeprefix(sharing.SHARING_SEPARATOR_GROUP).split(',') + logger.trace("sharing/list/check/groups: groups_of_user=%r Groups=%r", groups_of_user, Groups) + found = False + for group in groups_of_user: + if group in Groups: + found = True + break + if found: + pass + else: + continue + elif row['User'] == User: + pass + else: + continue + if group_check and User.endswith(row['User']): + pass + elif row['User'] == User: + pass + else: + continue + + row_copy = row.copy() + + if group_check and User is not None: + if row['User'].startswith(sharing.SHARING_SEPARATOR_GROUP): + user_without_group = User.split(sharing.SHARING_SEPARATOR_GROUP)[0] + else: + user_without_group = User + row_copy['PathOrToken'] = row['PathOrToken'].replace("{user}", user_without_group) # replace placeholder + row_copy['User'] = user_without_group # replace with real user + + if PathOrToken is not None and row_copy['PathOrToken'] != PathOrToken: + continue + + logger.trace("sharing/list/row: add : %r", row_copy) + result.append(row_copy) return result From d24a9b8c04c7ccc9b775b4398ac30a8e1ea35c83 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 18 Jul 2026 15:08:17 +0200 Subject: [PATCH 08/26] group: generic tests --- radicale/tests/test_auth.py | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index e7e15a47..a3f94cf5 100644 --- a/radicale/tests/test_auth.py +++ b/radicale/tests/test_auth.py @@ -149,6 +149,62 @@ class TestBaseAuthRequests(BaseTest): check = 401 self._test_htpasswd("plain", "😀:🔑", "unicode", check=check) + def test_htpasswd_invalid_user_start_with_at(self) -> None: + """user start with @ is not permitted""" + self._test_htpasswd("plain", "@domain.example:test", ( + ("@domain.example", "test", True), ("@domain.example", "test", False)), check=401) + + def test_htpasswd_invalid_user_end_with_at(self) -> None: + """user end with @ is not permitted""" + self._test_htpasswd("plain", "domain.example@:test", ( + ("domain.example@", "test", True), ("domain.example@", "test", False)), check=401) + + def test_htpasswd_invalid_user_start_with_encoded_at(self) -> None: + """user start with encoded @ is not permitted""" + self.configure({"auth": {"urldecode_username": "True"}}) + self._test_htpasswd("plain", "@domain.example:test", ( + ("%40domain.example", "test", True), ("%40domain.example", "test", False)), check=401) + + def test_htpasswd_invalid_user_end_with_encoded_at(self) -> None: + """user end with encoded @ is not permitted""" + self.configure({"auth": {"urldecode_username": "True"}}) + self._test_htpasswd("plain", "domain.example@:test", ( + ("domain.example%40", "test", True), ("domain.example%40", "test", False)), check=401) + + def test_htpasswd_invalid_user_with_more_encoded_at(self) -> None: + """user with more encoded @ is not permitted""" + self.configure({"auth": {"urldecode_username": "True"}}) + self._test_htpasswd("plain", "user@group@domain.example:test", ( + ("user%40group%40domain.example", "test", True), ("user%40group%40domain.example", "test", False)), check=401) + + def test_htpasswd_invalid_user_start_with_colon(self) -> None: + """user start with : is not permitted""" + try: + self._test_htpasswd("plain", ":group:test", ( + (":group", "test", True), (":group", "test", False)), check=401) + except RuntimeError: + pass + else: + raise + + def test_htpasswd_invalid_user_start_with_encoded_colon(self) -> None: + """user start with encoded : is not permitted""" + self.configure({"auth": {"urldecode_username": "True"}}) + self._test_htpasswd("plain", "'%3Adomain.example:test", ( + ("%3Adomain.example", "test", True), ("%3Adomain.example", "test", False)), check=401) + + def test_htpasswd_invalid_user_end_with_encoded_colon(self) -> None: + """user end with encoded : is not permitted""" + self.configure({"auth": {"urldecode_username": "True"}}) + self._test_htpasswd("plain", "domain.example:test", ( + ("domain.example%3A", "test", True), ("domain.example%3A", "test", False)), check=401) + + def test_htpasswd_invalid_user_with_any_encoded_colon(self) -> None: + """user with any encoded : is not permitted""" + self.configure({"auth": {"urldecode_username": "True"}}) + self._test_htpasswd("plain", "user%3Adomain.example:test", ( + ("user%3Adomain.example", "test", True), ("user%3Adomain.example", "test", False)), check=401) + def test_htpasswd_md5(self) -> None: self._test_htpasswd("md5", "tmp:$apr1$BI7VKCZh$GKW4vq2hqDINMr8uv7lDY/") From f0a7e927b49461a40b1e726f82fb2e7e0957195b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 18 Jul 2026 15:08:40 +0200 Subject: [PATCH 09/26] group: sharing tests --- radicale/tests/test_sharing.py | 246 ++++++++++++++++++++++++++++++++- 1 file changed, 244 insertions(+), 2 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index be26c11f..7c8f2258 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -48,6 +48,7 @@ class TestSharingApiSanity(BaseTest): def setup_method(self) -> None: BaseTest.setup_method(self) self.htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") + self.htgroup_file_path = os.path.join(self.colpath, ".htgroup") encoding: str = self.configuration.get("encoding", "stock") htpasswd = ["owner:ownerpw", "user:userpw", "owner1:owner1pw", "user1:user1pw", @@ -55,11 +56,25 @@ class TestSharingApiSanity(BaseTest): "owner.surename@domain.example:owner@pw", "user.surename@domain.example:user@pw", "owner-surename@domain.example:owner@pw", "user-surename@domain.example:user@pw", "owner_surename@domain.example:owner@pw", "user_surename@domain.example:user@pw", + "user1@domain.example:user1@pw", "user2@domain.example:user2@pw", + "user3:user3pw", "user4:user4", + "user1@domain.tld:user1@pw", "user2@domain.tld:user2@pw", "us😀er:user😀pw", "owner2:owner2pw", "user2:user2pw"] + htgroup = ["group1:user1", + "group2:user2", + "group3:user3", + "group4:user4", + "group12:user1 user2", + "group13:user1 user3", + "group23:user2 user3", + ] htpasswd_content = "\n".join(htpasswd) + htgroup_content = "\n".join(htgroup) with open(self.htpasswd_file_path, "w", encoding=encoding) as f: f.write(htpasswd_content) + with open(self.htgroup_file_path, "w", encoding=encoding) as f: + f.write(htgroup_content) # Helper functions def _sharing_api(self, sharing_type: str, action: str, check: int, login: Union[str, None], data: str, content_type: str, accept: Union[str, None], x_forwarded_for: Union[str, None] = None) -> Tuple[int, Dict[str, str], str]: @@ -114,7 +129,7 @@ class TestSharingApiSanity(BaseTest): assert status == 200 return prop.text - def _proppatch_calendar_color(self, path, login, color) -> None: + def _proppatch_calendar_color(self, path, login, color, check=207) -> None: _, responses = self.proppatch(path=path, data="""\ @@ -123,7 +138,9 @@ class TestSharingApiSanity(BaseTest): """ + color + """ -""", login=login) +""", login=login, check=check) + if check != 207: + return logging.info("response: %r", responses) response = responses[path] assert not isinstance(response, int) and len(response) == 1 @@ -6956,3 +6973,228 @@ permissions: RrWw""") json_dict['Enabled'] = True json_dict['Hidden'] = False _, headers, answer = self._sharing_api_json("map", "create", check=400, login="owner:ownerpw", json_dict=json_dict) + + def test_sharing_api_map_user_group_by_domain(self) -> None: + """share-by-map API usage tests related user group by domain.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_map": "True", + "permit_create_token": "False", + "collection_by_map": "True", + "collection_by_token": "False"}, + "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_mapped2 = "/owner/calendarPFP2-" + db_type + ".ics/" + path_shared_r = "/{user}/calendarPFP-shared-by-owner-r-" + db_type + ".ics/" + path_shared2_r = "/{user}/calendarPFP2-shared-by-owner-r-" + db_type + ".ics/" + path_shared_r_base = "/{user}/" + self.mkcalendar(path_mapped, login="owner:ownerpw") + self.mkcalendar(path_mapped2, login="owner:ownerpw") + + # create map + logging.info("\n*** create map @domain/owner:rP -> success") + json_dict = {} + json_dict['User'] = "@domain.example" + 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) + + # create map + logging.info("\n*** create map @domain/owner:rP -> success") + json_dict = {} + json_dict['User'] = "@domain.example" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_shared2_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) + + # verify PROPFIND as user1 + logging.info("\n*** PROPFIND collection user1@domain.example") + path_shared_r_user = path_shared_r.replace("{user}", "user1@domain.example") + _, responses = self.propfind(path_shared_r_user, """\ + + + +""", login="user1@domain.example:user1@pw") + assert path_shared_r_user.replace('@', '%40') in responses + + # verify PROPFIND as user2 + logging.info("\n*** PROPFIND collection user2@domain.example") + path_shared_r_user = path_shared_r.replace("{user}", "user2@domain.example") + _, responses = self.propfind(path_shared_r_user, """\ + + + +""", login="user2@domain.example:user2@pw") + assert path_shared_r_user.replace('@', '%40') in responses + + # verify PROPFIND as user1 + logging.info("\n*** PROPFIND collection user1@domain.tld") + path_shared_r_user = path_shared_r.replace("{user}", "user1@domain.tld") + _, responses = self.propfind(path_shared_r_user, """\ + + + +""", login="user1@domain.tld:user1@pw", check=404) + + # verify PROPFIND as user2 + logging.info("\n*** PROPFIND collection user2@domain.tld") + path_shared_r_user = path_shared_r.replace("{user}", "user2@domain.tld") + _, responses = self.propfind(path_shared_r_user, """\ + + + +""", login="user2@domain.tld:user2@pw", check=404) + + # verify PROPFIND as user1 in list + logging.info("\n*** PROPFIND collection DEPTH=1 user1@domain.example") + path_shared_r_base_user = path_shared_r_base.replace("{user}", "user1@domain.example") + path_shared_r_user = path_shared_r.replace("{user}", "user1@domain.example") + path_shared2_r_user = path_shared_r.replace("{user}", "user1@domain.example") + _, responses = self.propfind(path_shared_r_base_user, """\ + + + +""", login="user1@domain.example:user1@pw", HTTP_DEPTH="1") + assert path_shared_r_base_user.replace('@', '%40') in responses + assert path_shared_r_user.replace('@', '%40') in responses + assert path_shared2_r_user.replace('@', '%40') in responses + + # execute PROPPATCH as user + logging.info("\n*** PROPPATCH collection user1@domain.example -> forbidden") + self._proppatch_calendar_color(path_shared_r_user, login="user1@domain.example:user1@pw", color="#FFFFFF", check=403) + + # verify PROPFIND as user1 not in list + logging.info("\n*** PROPFIND collection DEPTH=1 user1@domain.tld") + path_shared_r_base_user = path_shared_r_base.replace("{user}", "user1@domain.tld") + path_shared_r_user = path_shared_r.replace("{user}", "user1@domain.tld") + _, responses = self.propfind(path_shared_r_base_user, """\ + + + +""", login="user1@domain.tld:user1@pw", HTTP_DEPTH="1") + assert path_shared_r_base_user.replace('@', '%40') in responses + assert path_shared_r_user.replace('@', '%40') not in responses + + logging.info("\n*** PROPPATCH collection user1@domain.tld -> not found") + self._proppatch_calendar_color(path_shared_r_user, login="user1@domain.tld:user1@pw", color="#FFFFFF", check=404) + + def test_sharing_api_map_user_group_by_local(self) -> None: + """share-by-map API usage tests related user group by local.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "group": {"type": "htgroup", + "htgroup_filename": self.htgroup_file_path}, + "sharing": { + "type": "csv", + "permit_create_map": "True", + "permit_create_token": "False", + "collection_by_map": "True", + "collection_by_token": "False"}, + "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_mapped1 = "/owner/calendarUGBL1-" + db_type + ".ics/" + path_mapped2 = "/owner/calendarUGBL2-" + db_type + ".ics/" + path_mapped3 = "/owner/calendarUGBL3-" + db_type + ".ics/" + path_shared1_r = "/{user}/calendarUGBL1-shared-by-owner-r-" + db_type + ".ics/" + path_shared2_r = "/{user}/calendarUGBL2-shared-by-owner-r-" + db_type + ".ics/" + path_shared3_r = "/{user}/calendarUGBL3-shared-by-owner-r-" + db_type + ".ics/" + path_shared_r_base = "/{user}/" + self.mkcalendar(path_mapped1, login="owner:ownerpw") + self.mkcalendar(path_mapped2, login="owner:ownerpw") + self.mkcalendar(path_mapped3, login="owner:ownerpw") + + # create map + logging.info("\n*** create map :group1/owner -> success") + json_dict = {} + json_dict['User'] = ":group1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_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) + + logging.info("\n*** create map :group2/owner -> success") + json_dict = {} + json_dict['User'] = ":group2" + json_dict['PathMapped'] = path_mapped2 + json_dict['PathOrToken'] = path_shared2_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) + + logging.info("\n*** create map :group1,group2/owner -> success") + json_dict = {} + json_dict['User'] = ":group1,group2" + json_dict['PathMapped'] = path_mapped3 + json_dict['PathOrToken'] = path_shared3_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) + + # verify PROPFIND as user1 in list + logging.info("\n*** PROPFIND collection DEPTH=1 user1") + path_shared_r_base_user = path_shared_r_base.replace("{user}", "user1") + path_shared1_r_user = path_shared1_r.replace("{user}", "user1") + path_shared2_r_user = path_shared2_r.replace("{user}", "user1") + path_shared3_r_user = path_shared3_r.replace("{user}", "user1") + _, responses = self.propfind(path_shared_r_base_user, """\ + + + +""", login="user1:user1pw", HTTP_DEPTH="1") + assert path_shared_r_base_user in responses + assert path_shared1_r_user in responses + assert path_shared2_r_user not in responses + assert path_shared3_r_user in responses + + # verify PROPFIND as user2 in list + logging.info("\n*** PROPFIND collection DEPTH=1 user2") + path_shared_r_base_user = path_shared_r_base.replace("{user}", "user2") + path_shared1_r_user = path_shared1_r.replace("{user}", "user2") + path_shared2_r_user = path_shared2_r.replace("{user}", "user2") + path_shared3_r_user = path_shared3_r.replace("{user}", "user2") + _, responses = self.propfind(path_shared_r_base_user, """\ + + + +""", login="user2:user2pw", HTTP_DEPTH="1") + assert path_shared_r_base_user in responses + assert path_shared1_r_user not in responses + assert path_shared2_r_user in responses + assert path_shared3_r_user in responses From 5e4dd2451added91ba083e95fc07609c41aaf24e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 18 Jul 2026 15:20:35 +0200 Subject: [PATCH 10/26] group: sharing/doc updated --- DOCUMENTATION.md | 4 ++++ SHARING.md | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index d188b557..a5188604 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2341,6 +2341,8 @@ Default: `false` * If `False` it can be explicitly granted by *share* permissions: `P` * If `True` it can be explicitly forbidden by *share* permissions: `p` +share-by-group/realm: always forbidden (_>= 3.8.0_) + ##### enforce_properties_overlay _(>= 3.7.0)_ @@ -2352,6 +2354,8 @@ Default: `true` * If `False` it can be explicitly enforced by *share* permissions: `E` * If `True` it can be explicitly forbidden by *share* permissions: `e` +share-by-group/realm: always forbidden (_>= 3.8.0_) + ##### default_permissions_create_token _(>= 3.7.0)_ diff --git a/SHARING.md b/SHARING.md index 362ada42..9c1f55cc 100644 --- a/SHARING.md +++ b/SHARING.md @@ -8,6 +8,10 @@ With _3.7.0_ major extension was implemented * added management API * WebUI extension using the management API +With _3.8.0_ sharing-by-* membership was implemented + * sharing-by-group + * sharing-by-realm + ## Sharing Implementation Implementation of sharing collections is done by using a database to lookup the URI and in case entry exists by mapping to target URI and replacing provided data on request and adjust if required data in response. @@ -32,17 +36,22 @@ Types of supported sharing configuration: * `map`: map-based share (requires user authentication) * `PathOrToken`: token or "virtual" collection, has to be unique (PRIMARY KEY) * `PathMapped`: target collection + * share-by-group/realm: has to start with placeholder `/{user}` (_>= 3.8.0_) * `Conversion`: conversion method * `Owner`: owner of the share - * `User`: user of the share + * `User`: user (or group, _>= 3.8.0_) of the share + * share-by-group/realm: has to start with `:` or `@` (_>= 3.8.0_) * `Permissions`: effective permission of the share * `EnabledByOwner`: control by owner * `EnabledByUser`: control by user + * share-by-group/realm: always enabled (_>= 3.8.0_) * `HiddenByOwner`: control by owner * `HiddenByUser`: control by user + * share-by-group/realm: always disabled (_>= 3.8.0_) * `TimestampCreated`: unixtime of creation * `TimestampUpdated`: unixtime of last update * `Properties`: overlay properties (limited set whitelisted) + * share-by-group/realm: not supported (_>= 3.8.0_) * `Actions`: specific configuration `Enabled*`: _owner_ AND _user_ have to enable a share to become usable From 1ff4c6ca0340e296f075ab5daf80c44bc76bd20c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 11:04:46 +0200 Subject: [PATCH 11/26] sharing: share-by-group: add share resolution for other methods beside propfind --- radicale/app/delete.py | 6 +++++- radicale/app/get.py | 7 +++++-- radicale/app/move.py | 12 +++++++++--- radicale/app/propfind.py | 5 ++++- radicale/app/proppatch.py | 5 ++++- radicale/app/put.py | 9 ++++++--- radicale/app/report.py | 7 +++++-- 7 files changed, 38 insertions(+), 13 deletions(-) diff --git a/radicale/app/delete.py b/radicale/app/delete.py index c1594fbb..69fd54e6 100644 --- a/radicale/app/delete.py +++ b/radicale/app/delete.py @@ -23,7 +23,7 @@ from http import client from typing import Optional, Union from urllib.parse import quote -from radicale import httputils, storage, types, xmlutils +from radicale import httputils, sharing, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.hook import HookNotificationItem, HookNotificationItemTypes from radicale.log import logger @@ -71,6 +71,10 @@ class ApplicationPartDelete(ApplicationBase): if self._sharing._enabled: # Sharing by token or map (if enabled) share = self._sharing.sharing_collection_resolver(path, user) + user_lookup = user + if self._rights._user_groups is not None and len(self._rights._user_groups) > 0: + user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups) + share = self._sharing.sharing_collection_resolver(path, user_lookup) if share: # overwrite and run through extended permission check path = share['PathMapped'] diff --git a/radicale/app/get.py b/radicale/app/get.py index 542ff4d2..47f6b63c 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -23,7 +23,7 @@ from http import client from typing import Union from urllib.parse import quote -from radicale import httputils, pathutils, storage, types, xmlutils +from radicale import httputils, pathutils, sharing, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.log import logger @@ -89,7 +89,10 @@ class ApplicationPartGet(ApplicationBase): share = None if self._sharing._enabled: # Sharing by token or map (if enabled) - share = self._sharing.sharing_collection_resolver(path, user) + user_lookup = user + if self._rights._user_groups is not None and len(self._rights._user_groups) > 0: + user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups) + share = self._sharing.sharing_collection_resolver(path, user_lookup) if share: # overwrite and run through extended permission check path = share['PathMapped'] diff --git a/radicale/app/move.py b/radicale/app/move.py index 1d84c58b..4e4b7cd1 100644 --- a/radicale/app/move.py +++ b/radicale/app/move.py @@ -24,7 +24,7 @@ import re from http import client from urllib.parse import unquote, urlparse -from radicale import httputils, pathutils, storage, types +from radicale import httputils, pathutils, sharing, storage, types from radicale.app import base as app_base from radicale.app.base import Access, ApplicationBase from radicale.log import logger @@ -73,7 +73,10 @@ class ApplicationPartMove(ApplicationBase): permissions_filter = None if self._sharing._enabled: # Sharing by token or map (if enabled) - share = self._sharing.sharing_collection_resolver(path, user) + user_lookup = user + if self._rights._user_groups is not None and len(self._rights._user_groups) > 0: + user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups) + share = self._sharing.sharing_collection_resolver(path, user_lookup) if share: # overwrite and run through extended permission check path = share['PathMapped'] @@ -93,7 +96,10 @@ class ApplicationPartMove(ApplicationBase): to_path = to_path[len(base_prefix):] if self._sharing._enabled: # Sharing by token or map (if enabled) - share = self._sharing.sharing_collection_resolver(to_path, to_user) + to_user_lookup = to_user + if self._rights._user_groups is not None and len(self._rights._user_groups) > 0: + to_user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups) + share = self._sharing.sharing_collection_resolver(to_path, to_user_lookup) if share: # overwrite and run through extended permission check to_path = share['PathMapped'] diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 531dd52e..7442f885 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -588,7 +588,10 @@ class ApplicationPartPropfind(ApplicationBase): allowed_items: list = [] if self._sharing._enabled: # Sharing by token or map (if enabled) - share = self._sharing.sharing_collection_resolver(path, user) + user_lookup = user + if self._rights._user_groups is not None and len(self._rights._user_groups) > 0: + user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups) + share = self._sharing.sharing_collection_resolver(path, user_lookup) if share: # overwrite and run through extended permission check path = share['PathMapped'] diff --git a/radicale/app/proppatch.py b/radicale/app/proppatch.py index ee7f8685..d6c29c78 100644 --- a/radicale/app/proppatch.py +++ b/radicale/app/proppatch.py @@ -107,7 +107,10 @@ class ApplicationPartProppatch(ApplicationBase): path_orig = path if self._sharing._enabled: # Sharing by token or map (if enabled) - share = self._sharing.sharing_collection_resolver(path, user) + user_lookup = user + if self._rights._user_groups is not None and len(self._rights._user_groups) > 0: + user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups) + share = self._sharing.sharing_collection_resolver(path, user_lookup) if share: # overwrite and run through extended permission check path = share['PathMapped'] diff --git a/radicale/app/put.py b/radicale/app/put.py index b6898103..1013504d 100644 --- a/radicale/app/put.py +++ b/radicale/app/put.py @@ -33,8 +33,8 @@ from typing import Iterator, List, Mapping, MutableMapping, Optional, Tuple import vobject import radicale.item as radicale_item -from radicale import (httputils, pathutils, rights, storage, types, utils, - xmlutils) +from radicale import (httputils, pathutils, rights, sharing, storage, types, + utils, xmlutils) from radicale.app.base import Access, ApplicationBase from radicale.hook import HookNotificationItem, HookNotificationItemTypes from radicale.log import logger @@ -188,7 +188,10 @@ class ApplicationPartPut(ApplicationBase): permissions_filter = None if self._sharing._enabled: # Sharing by token or map (if enabled) - share = self._sharing.sharing_collection_resolver(path, user) + user_lookup = user + if self._rights._user_groups is not None and len(self._rights._user_groups) > 0: + user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups) + share = self._sharing.sharing_collection_resolver(path, user_lookup) if share: # overwrite and run through extended permission check path = share['PathMapped'] diff --git a/radicale/app/report.py b/radicale/app/report.py index ed8a147d..8c0580cf 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -38,7 +38,7 @@ import vobject.base from vobject.base import ContentLine import radicale.item as radicale_item -from radicale import httputils, pathutils, storage, types, xmlutils +from radicale import httputils, pathutils, sharing, storage, types, xmlutils from radicale.app.base import Access, ApplicationBase from radicale.item import filter as radicale_filter from radicale.log import logger @@ -864,7 +864,10 @@ class ApplicationPartReport(ApplicationBase): share = None if self._sharing._enabled: # Sharing by token or map (if enabled) - share = self._sharing.sharing_collection_resolver(path, user) + user_lookup = user + if self._rights._user_groups is not None and len(self._rights._user_groups) > 0: + user_lookup += sharing.SHARING_SEPARATOR_GROUP + ','.join(self._rights._user_groups) + share = self._sharing.sharing_collection_resolver(path, user_lookup) if share: # overwrite and run through extended permission check path = share['PathMapped'] From f6f965f07a77dcef59cfa5de9eb0b69aee95685a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 11:05:43 +0200 Subject: [PATCH 12/26] sharing: fix group separator --- radicale/sharing/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index f5d3680d..b5a962eb 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -1345,7 +1345,7 @@ class BaseSharing: logger.warning(api_info + ": PathMapped=%r change of Conversion %r -> %r is not supported", PathMapped, share['Conversion'], Conversion) return httputils.bad_request("Change of conversion is not supported") - if User is not None and (User.startswith('!') or User.startswith('@')): + if (User is not None and (User.startswith(SHARING_SEPARATOR_GROUP) or User.startswith(SHARING_SEPARATOR_REALM))) or (share['User'].startswith(SHARING_SEPARATOR_GROUP) or share['User'].startswith(SHARING_SEPARATOR_REALM)): # enforce user permissions for groups if Permissions is not None: if "e" not in Permissions: From c1135c87cb789f0291d8c3d6a7f5a7d8aab5f689 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 11:06:05 +0200 Subject: [PATCH 13/26] sharing: share-by-group: add additional test cases for other methods than PROPFIND --- radicale/tests/test_sharing.py | 98 ++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 7c8f2258..9f218b7a 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -7198,3 +7198,101 @@ permissions: RrWw""") assert path_shared1_r_user not in responses assert path_shared2_r_user in responses assert path_shared3_r_user in responses + + # try upload item as user1 -> fail (w permission missing) + logging.info("\n*** PUT to shared1 as user1 -> 403") + path_shared1_r_user = path_shared1_r.replace("{user}", "user1") + event = get_file_content("event1.ics") + self.put(path_shared1_r_user, event, login="user1:user1pw", check=403) + + # update permissions + logging.info("\n*** update map :group1/owner -> success") + json_dict = {} + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_r + json_dict['Permissions'] = "rw" + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** update map :group3/owner -> success") + json_dict = {} + json_dict['PathMapped'] = path_mapped3 + json_dict['PathOrToken'] = path_shared3_r + json_dict['Permissions'] = "rw" + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + + # upload item as user1 -> success + logging.info("\n*** PUT to shared1 as user1 -> 201") + path_shared1_r_user = path_shared1_r.replace("{user}", "user1") + event = get_file_content("event1.ics") + self.put(path_shared1_r_user, event, login="user1:user1pw") + + # propfind as user1 -> success + logging.info("\n*** PROPFIND collection user1") + path_shared1_r_user = path_shared1_r.replace("{user}", "user1") + _, responses = self.propfind(path_shared1_r_user, """\ + + + +""", login="user1:user1pw") + assert path_shared1_r_user in responses + + # report as user1 -> success + logging.info("\n*** REPORT collection user1") + path_shared1_r_user = path_shared1_r.replace("{user}", "user1") + item_shared1_r_user = path_shared1_r.replace("{user}", "user1") + "event1.ics" + _, responses = self.report(path_shared1_r_user, """\ + + + + + +""", login="user1:user1pw") + assert item_shared1_r_user in responses + + # report as user2 -> success + logging.info("\n*** REPORT collection user2") + path_shared1_r_user = path_shared1_r.replace("{user}", "user2") + item_shared1_r_user = path_shared1_r.replace("{user}", "user2") + "event1.ics" + _, responses = self.report(path_shared1_r_user, """\ + + + + + +""", login="user2:user2pw", check=404) + + # get item as user1 -> success + logging.info("\n*** GET from shared1 as user1") + item_shared1_r_user = path_shared1_r.replace("{user}", "user1") + "event1.ics" + self.get(item_shared1_r_user, login="user1:user1pw") + + # get item as user2 -> 404 + logging.info("\n*** GET from shared3 as user2") + item_shared3_r_user = path_shared1_r.replace("{user}", "user2") + "event1.ics" + self.get(item_shared3_r_user, login="user2:user2pw", check=404) + + # move item as user1 -> success + logging.info("\n*** MOVE item shared1 to shared3 as user1") + item_shared1_r_user = path_shared1_r.replace("{user}", "user1") + "event1.ics" + item_shared3_r_user = path_shared3_r.replace("{user}", "user1") + "event1.ics" + self.request("MOVE", item_shared1_r_user, login="user1:user1pw", HTTP_DESTINATION="http://127.0.0.1"+item_shared3_r_user) + + # get item as user2 -> 200 + logging.info("\n*** GET from shared3 as user2") + item_shared3_r_user = path_shared3_r.replace("{user}", "user2") + "event1.ics" + self.get(item_shared3_r_user, login="user2:user2pw") + + # delete item as user1 -> 404 + logging.info("\n*** DELETE from shared1 as user1 -> 404") + item_shared1_r_user = path_shared1_r.replace("{user}", "user1") + "event1.ics" + self.delete(item_shared1_r_user, login="user1:user1pw", check=404) + + # delete item as user1 + logging.info("\n*** DELETE from shared3 as user1 -> 200") + item_shared3_r_user = path_shared3_r.replace("{user}", "user1") + "event1.ics" + self.delete(item_shared3_r_user, login="user1:user1pw") + + # try proppatch -> 403 + logging.info("\n*** PROPPATCH shared3 as user1 -> 403") + path_shared3_r_user = path_shared3_r.replace("{user}", "user1") + self._proppatch_calendar_color(path_shared3_r_user, login="user1:user1pw", color="#FFFFFF", check=403) From cd73937132812c9be6219cd9ac6f1db68f21352d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 12:30:46 +0200 Subject: [PATCH 14/26] group/htgroup: allow empty group --- radicale/group/htgroup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/group/htgroup.py b/radicale/group/htgroup.py index 55aa0c4b..92a18bce 100644 --- a/radicale/group/htgroup.py +++ b/radicale/group/htgroup.py @@ -88,7 +88,7 @@ class Group(group.BaseGroup): try: group, members = line.split(":", maxsplit=1) skip = False - if group == "" or members == "": + if group == "": if init is True: raise ValueError("htgroup file contains problematic line not matching : in line: %d" % line_num) else: From 912ea717a83b465e445f2530900adfab5384782d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 12:31:03 +0200 Subject: [PATCH 15/26] group: add additional tests --- radicale/tests/test_group.py | 139 +++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 radicale/tests/test_group.py diff --git a/radicale/tests/test_group.py b/radicale/tests/test_group.py new file mode 100644 index 00000000..e5f79352 --- /dev/null +++ b/radicale/tests/test_group.py @@ -0,0 +1,139 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Radicale tests related to group lookup. + +""" + +import logging +import os + +import pytest + +import radicale +from radicale.tests import BaseTest + + +class TestBaseGroupRequests(BaseTest): + """Tests basic requests with group lookup. + + We should setup auth for each type before creating the Application object. + + """ + + def _test_htgroup(self, htpasswd_content: str, htgroup_content, check: int = 207) -> None: + """Test htpasswd authentication with user "tmp" and password "bepo" for + """ + htpasswd_file_path = os.path.join(self.colpath, ".htpasswd") + htgroup_file_path = os.path.join(self.colpath, ".htgroup") + encoding: str = self.configuration.get("encoding", "stock") + with open(htpasswd_file_path, "w", encoding=encoding) as f: + f.write(htpasswd_content) + with open(htgroup_file_path, "w", encoding=encoding) as f: + f.write(htgroup_content) + self.configure({"auth": {"type": "htpasswd", + "delay": 0, + "htpasswd_filename": htpasswd_file_path, + "htpasswd_encryption": "autodetect"}, + "group": {"type": "htgroup", + "htgroup_filename": htgroup_file_path}, + "server": {"delay_on_error": 0}}) + self.propfind("/", check=check, + login="%s:%s" % ("tmp", "bepo")) + + @pytest.mark.skipif(radicale.log.logger.getEffectiveLevel() == logging.INFO, reason="requires loglevel DEBUG") + def test_htgroup_simple(self, caplog) -> None: + caplog.set_level(logging.DEBUG) + self._test_htgroup(htpasswd_content="tmp:bepo", + htgroup_content="group:tmp") + logs = caplog.messages + assert len([log for log in logs if "Group memberships (htgroup) for user 'tmp': {'group'}" in log]) == 1 + + @pytest.mark.skipif(radicale.log.logger.getEffectiveLevel() == logging.INFO, reason="requires loglevel DEBUG") + def test_htgroup_more_groups(self, caplog) -> None: + caplog.set_level(logging.DEBUG) + self._test_htgroup(htpasswd_content="tmp:bepo", + htgroup_content="group1:tmp\ngroup2:tmp\ngroup3:user") + logs = caplog.messages + assert len([log for log in logs + if "Group memberships (htgroup) for user 'tmp': {'group2', 'group1'}" in log + or "Group memberships (htgroup) for user 'tmp': {'group1', 'group2'}" in log + ]) == 1 + + @pytest.mark.skipif(radicale.log.logger.getEffectiveLevel() == logging.INFO, reason="requires loglevel DEBUG") + def test_htgroup_more_empty_groups(self, caplog) -> None: + caplog.set_level(logging.DEBUG) + self._test_htgroup(htpasswd_content="tmp:bepo", + htgroup_content="group1:tmp\ngroup2:tmp\ngroup3:user\ngroup4:") + logs = caplog.messages + assert len([log for log in logs + if "Group memberships (htgroup) for user 'tmp': {'group2', 'group1'}" in log + or "Group memberships (htgroup) for user 'tmp': {'group1', 'group2'}" in log + ]) == 1 + + @pytest.mark.skipif(radicale.log.logger.getEffectiveLevel() == logging.INFO, reason="requires loglevel DEBUG") + def test_htgroup_more_users(self, caplog) -> None: + caplog.set_level(logging.DEBUG) + self._test_htgroup(htpasswd_content="tmp:bepo", + htgroup_content="group1:tmp user1\ngroup2:tmp user2\ngroup3:user3 user2") + logs = caplog.messages + assert len([log for log in logs + if "Group memberships (htgroup) for user 'tmp': {'group2', 'group1'}" in log + or "Group memberships (htgroup) for user 'tmp': {'group1', 'group2'}" in log + ]) == 1 + + @pytest.mark.skipif(radicale.log.logger.getEffectiveLevel() == logging.INFO, reason="requires loglevel DEBUG") + def test_htgroup_unauthenticated_user(self, caplog) -> None: + caplog.set_level(logging.DEBUG) + self._test_htgroup(htpasswd_content="tmp:bepo1", + htgroup_content="group1:tmp user1\ngroup2:tmp user2\ngroup3:user3 user2", check=401) + logs = caplog.messages + assert len([log for log in logs + if "Group memberships (htgroup) for user 'tmp': {'group2', 'group1'}" in log + or "Group memberships (htgroup) for user 'tmp': {'group1', 'group2'}" in log + ]) == 0 + + def test_incompatible_group_auth_type(self) -> None: + for auth_type in ["dovecot", "imap", "remote_user", "http_remote_user", "htpasswd", "oauth2"]: + logging.info("\n*** test: auth_type=%r, group_type=%r", "dovecot", auth_type) + try: + self.configure( + {"auth": { + "type": auth_type, + "oauth2_token": "dummy", + }, + "group": {"type": "auth-type"} + }) + except RuntimeError: + pass + else: + raise + + for auth_type in ["pam", "ldap"]: + logging.info("\n*** test: auth_type=%r, group_type=%r", "dovecot", auth_type) + try: + self.configure( + {"auth": { + "type": auth_type, + }, + "group": {"type": "auth-type"} + }) + except RuntimeError: + raise + else: + pass + From d9a02849967cf005f601ab2d362ea8fd211c12b3 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 14:35:18 +0200 Subject: [PATCH 16/26] group: introduce type auth_type, extend for PAM module --- config | 2 +- radicale/app/__init__.py | 17 ++++++++++------- radicale/auth/__init__.py | 2 +- radicale/auth/ldap.py | 4 ++-- radicale/auth/pam.py | 5 +++++ radicale/group/__init__.py | 1 + radicale/tests/test_group.py | 5 ++--- 7 files changed, 22 insertions(+), 14 deletions(-) diff --git a/config b/config index 3b791f5d..01cf8046 100644 --- a/config +++ b/config @@ -214,7 +214,7 @@ [group] # Group lookup method -# Value: none | htgroup +# Value: none | auth_type | htgroup type = none # Htgroup filename diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index 7643567e..ab685362 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -598,14 +598,17 @@ class Application(ApplicationPartDelete, ApplicationPartHead, user = "" if user: - if self.configuration.get("group", "type") != "none": + group_type = self.configuration.get("group", "type") + if group_type in ["htgroup"]: self._rights._user_groups = self._group.groups(login) if login else set([]) - elif self.configuration.get("auth", "type") == "ldap": - try: - logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups)) - self._rights._user_groups = self._auth._ldap_groups - except AttributeError: - pass + elif group_type in ["auth_type"]: + auth_type = self.configuration.get("auth", "type") + if auth_type in ["ldap", "pam"]: + try: + logger.debug("Groups received from %r: %r", auth_type, ",".join(self._auth._groups)) + self._rights._user_groups = self._auth._groups + except AttributeError: + pass # Create principal collection if user: diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index 7a79d246..314e4f56 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -106,7 +106,7 @@ class AuthContext: class BaseAuth: - _ldap_groups: Set[str] = set([]) + _groups: Set[str] = set([]) _urldecode_username: bool _lc_username: bool _uc_username: bool diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py index c2f6efd2..cede4e6c 100644 --- a/radicale/auth/ldap.py +++ b/radicale/auth/ldap.py @@ -381,8 +381,8 @@ class Auth(auth.BaseAuth): tmp.append(rdns[0][1]) except Exception: tmp.append(g) - self._ldap_groups = set(tmp) - logger.debug("_login3 LDAP groups of user: %s", ",".join(self._ldap_groups)) + self._groups = set(tmp) + logger.debug("_login3 LDAP groups of user: %s", ",".join(self._groups)) if self._ldap_user_attr: if user_entry['attributes'][self._ldap_user_attr]: diff --git a/radicale/auth/pam.py b/radicale/auth/pam.py index 02727c85..0884fb20 100644 --- a/radicale/auth/pam.py +++ b/radicale/auth/pam.py @@ -97,6 +97,11 @@ class Auth(auth.BaseAuth): else: logger.debug("PAM user %r belongs to the required group: %r" % (login, self._group_membership)) + # add groups + members.append(primary_group) + self._groups = set(members) + logger.debug("PAM groups of user: %s", ",".join(self._groups)) + # Check the password if self.pam_authenticate(login, password, service=self._service): return login diff --git a/radicale/group/__init__.py b/radicale/group/__init__.py index 4cd8729d..92b2279b 100644 --- a/radicale/group/__init__.py +++ b/radicale/group/__init__.py @@ -29,6 +29,7 @@ from radicale import config, utils from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("none", + "auth_type", "htgroup", ) diff --git a/radicale/tests/test_group.py b/radicale/tests/test_group.py index e5f79352..cf4d0ee4 100644 --- a/radicale/tests/test_group.py +++ b/radicale/tests/test_group.py @@ -116,7 +116,7 @@ class TestBaseGroupRequests(BaseTest): "type": auth_type, "oauth2_token": "dummy", }, - "group": {"type": "auth-type"} + "group": {"type": "auth_type"} }) except RuntimeError: pass @@ -130,10 +130,9 @@ class TestBaseGroupRequests(BaseTest): {"auth": { "type": auth_type, }, - "group": {"type": "auth-type"} + "group": {"type": "auth_type"} }) except RuntimeError: raise else: pass - From ec22822b4c4a6f67649cb1e18ec1adfd055126ee Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 14:38:18 +0200 Subject: [PATCH 17/26] group: update doc --- DOCUMENTATION.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index a5188604..77a33d33 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1026,6 +1026,8 @@ Available types are: * `ldap` _(>= 3.3.0)_ Use a LDAP or AD server to authenticate users by relaying credentials from clients and handle results. + User groups are supported. Requires group/type=`auth_type` _(>= 3.8.0)_. + * `dovecot` _(>= 3.3.1)_ Use a Dovecot server to authenticate users by relaying credentials from clients and handle results. @@ -1038,7 +1040,9 @@ Available types are: in combination with SSO support in reverse proxy (e.g. Apache+mod_auth_openidc). * `pam` _(>= 3.5.0)_ - Use local PAM to authenticate users by relaying credentials from client and handle result.. + Use local PAM to authenticate users by relaying credentials from client and handle result. + + User groups are supported _(>= 3.8.0)_ Default: `none` _(< 3.5.0)_ / `denyall` _(>= 3.5.0)_ @@ -1261,6 +1265,8 @@ They also give you access to the group calendars, if those exist. Default: (unset) +Requires group lookup type set to `auth_type` _(>= 3.8.0)_ + ##### ldap_group_members_attribute _(>= 3.5.6)_ @@ -1505,13 +1511,18 @@ The method to lookup groups for username Available types are: * `none` - No groups lookup (exception: LDAP, see _auth_ section) + No groups lookup at all + +* `auth_type` + Group lookup by authentication type (if supported) * `htgroup` Use an [Apache htgroup file](https://httpd.apache.org/docs/2.4/mod/mod_authz_groupfile.html) to store groups and their members +Default: `none` + ##### htgroup_filename _(>= 3.8.0)_ From 46c52ee70335344de0804d41e15d1fd8e79301bd Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 14:38:41 +0200 Subject: [PATCH 18/26] group: add pam+ldap for tests --- pyproject.toml | 3 ++- setup.py.legacy | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ffbb9f67..006651b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,10 +40,11 @@ dependencies = [ [project.optional-dependencies] -test = ["pytest>=7", "waitress", "bcrypt", "argon2-cffi"] +test = ["pytest>=7", "waitress", "bcrypt", "argon2-cffi", "pam", "ldap3"] bcrypt = ["bcrypt"] argon2 = ["argon2-cffi"] ldap = ["ldap3"] +pam = ["pam"] dev = ["flake8", "isort", "mypy", "pytest", "pytest-playwright", "html5validator"] [project.scripts] diff --git a/setup.py.legacy b/setup.py.legacy index 0f5461e3..2d7855b0 100644 --- a/setup.py.legacy +++ b/setup.py.legacy @@ -45,6 +45,7 @@ install_requires = ["defusedxml", "libpass>=1.9.3", "vobject>=0.9.6", bcrypt_requires = ["bcrypt"] argon2_requires = ["argon2-cffi"] ldap_requires = ["ldap3"] +pam_requires = ["pam"] test_requires = ["pytest>=7", "waitress", *bcrypt_requires, *argon2_requires] setup( @@ -63,7 +64,7 @@ setup( package_data={"radicale": [*web_files, "py.typed"]}, entry_points={"console_scripts": ["radicale = radicale.__main__:run"]}, install_requires=install_requires, - extras_require={"test": test_requires, "bcrypt": bcrypt_requires, "argon2": argon2_requires, "ldap": ldap_requires}, + extras_require={"test": test_requires, "bcrypt": bcrypt_requires, "argon2": argon2_requires, "ldap": ldap_requires, "pam": pam_requires}, keywords=["calendar", "addressbook", "CalDAV", "CardDAV"], python_requires=">=3.9.0", classifiers=[ From 92d1ae9317786218fb6cd22eadebc936e2f72372 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 16:27:48 +0200 Subject: [PATCH 19/26] rights: add 2 support functions --- radicale/rights/__init__.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/radicale/rights/__init__.py b/radicale/rights/__init__.py index c6590e20..d886b18b 100644 --- a/radicale/rights/__init__.py +++ b/radicale/rights/__init__.py @@ -69,6 +69,32 @@ def intersect(a: str, b: str) -> str: return "".join(set(a).intersection(set(b))) +def remove(a: str, b: str) -> str: + """Remove rights from a defined in b + + Returns all rights of ``a`` not listed in ``b``. + + """ + result = set(a) + for entry in set(b): + if entry in a: + result.remove(entry) + return "".join(result) + + +def add(a: str, b: str) -> str: + """Add rights to a defined in b + + Returns all rights of ``a`` and ``b``. + + """ + result = set(a) + for entry in set(b): + if entry not in a: + result.add(entry) + return "".join(result) + + class BaseRights: _user_groups: Set[str] = set([]) From 593388e957a3239b7016d9ce43c3868befb5bcbf Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 16:28:44 +0200 Subject: [PATCH 20/26] sharing/group: improve permission check --- radicale/sharing/__init__.py | 37 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index b5a962eb..e1df7587 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -1093,10 +1093,9 @@ class BaseSharing: Permissions = str(Permissions) if Conversion == "bday": # bday is read-only and not supporting "Ee" - for permission in Permissions: - if permission not in "rPp": - logger.warning(api_info + ": PathMapped=%r Permissions=%r not supported for Conversion=%r", PathMapped, Permissions, Conversion) - return httputils.bad_request("Permissions are not supported for conversion") + if rights.intersect(Permissions, "Eew"): + logger.warning(api_info + ": PathMapped=%r Permissions=%r not supported for Conversion=%r", PathMapped, Permissions, Conversion) + return httputils.bad_request("Permissions are not supported for conversion: %r" % Permissions) if Enabled is None: Enabled = False # security by default @@ -1212,17 +1211,10 @@ class BaseSharing: # enforce user toggles for groups HiddenByUser = False EnabledByUser = True - if "E" in Permissions: - logger.warning(api_info + ": 'E' in Permissions=%r not allowed for group User=%r", Permissions, User) - return httputils.NOT_ALLOWED - elif "P" in Permissions: - logger.warning(api_info + ": 'P' in Permissions=%r not allowed for group User=%r", Permissions, User) - return httputils.NOT_ALLOWED - # enforce permissions for group - if "e" not in Permissions: - Permissions += "e" - if "p" not in Permissions: - Permissions += "p" + if rights.intersect(Permissions, "EP"): + logger.warning(api_info + ": PathMapped=%r Permissions=%r not supported for share-by-group/realm", PathMapped, Permissions) + return httputils.bad_request("Permissions are not supported for conversion: %r" % Permissions) + Permissions = rights.add(Permissions, "ep") # enforce permissions for group logger.trace("" + api_info + ": %r (Permissions=%r PathOrToken=%r Owner=%r User=%r)", PathMapped, Permissions, PathOrToken, user, User) @@ -1335,10 +1327,9 @@ class BaseSharing: Permissions = str(Permissions) if share['Conversion'] == "bday": # bday is read-only and not supporting "Ee" - for permission in Permissions: - if permission not in "rPp": - logger.warning(api_info + ": PathMapped=%r Permissions=%r not supported for Conversion=%r", PathMapped, Permissions, Conversion) - return httputils.bad_request("Permissions are not supported for conversion") + if rights.intersect(Permissions, "Eew"): + logger.warning(api_info + ": PathMapped=%r Permissions=%r not supported for Conversion=%r", PathMapped, Permissions, Conversion) + return httputils.bad_request("Permissions are not supported for conversion: %r" % Permissions) if Conversion is not None and share['Conversion'] is not None: if Conversion != share['Conversion']: @@ -1348,10 +1339,10 @@ class BaseSharing: if (User is not None and (User.startswith(SHARING_SEPARATOR_GROUP) or User.startswith(SHARING_SEPARATOR_REALM))) or (share['User'].startswith(SHARING_SEPARATOR_GROUP) or share['User'].startswith(SHARING_SEPARATOR_REALM)): # enforce user permissions for groups if Permissions is not None: - if "e" not in Permissions: - Permissions += "e" - if "p" not in Permissions: - Permissions += "p" + if rights.intersect(Permissions, "EP"): + logger.warning(api_info + ": PathMapped=%r Permissions=%r not supported for share-by-group/realm", PathMapped, Permissions) + return httputils.bad_request("Permissions are not supported for share-by-group/realm: %r" % Permissions) + Permissions = rights.add(Permissions, "ep") # enforce permissions for group if user == share['Owner']: if PathMapped is not None: From 36c459fec88af55ce429bfdfceeb89d81108994e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 19 Jul 2026 16:28:59 +0200 Subject: [PATCH 21/26] sharing: add additional test cases related to permissions --- radicale/tests/test_sharing.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 9f218b7a..4b3edc24 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -7137,6 +7137,36 @@ permissions: RrWw""") self.mkcalendar(path_mapped3, login="owner:ownerpw") # create map + logging.info("\n*** create map :group1/owner -> 400 (unsupported permissions)") + json_dict = {} + json_dict['User'] = ":group1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_r + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Hidden'] = False + _, headers, answer = self._sharing_api_json("map", "create", check=400, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** create map :group1/owner -> 400 (unsupported permissions)") + json_dict = {} + json_dict['User'] = ":group1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_r + json_dict['Permissions'] = "rE" + json_dict['Enabled'] = True + json_dict['Hidden'] = False + _, headers, answer = self._sharing_api_json("map", "create", check=400, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** create map :group1/owner -> 400 (unsupported permissions)") + json_dict = {} + json_dict['User'] = ":group1" + json_dict['PathMapped'] = path_mapped1 + json_dict['PathOrToken'] = path_shared1_r + json_dict['Permissions'] = "rEP" + json_dict['Enabled'] = True + json_dict['Hidden'] = False + _, headers, answer = self._sharing_api_json("map", "create", check=400, login="owner:ownerpw", json_dict=json_dict) + logging.info("\n*** create map :group1/owner -> success") json_dict = {} json_dict['User'] = ":group1" From 5abb0046e33c26d66117f0854ef5a328b5c85c4e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 31 Jul 2026 17:44:21 +0200 Subject: [PATCH 22/26] sharing/group: rename auth_type -> from_auth --- DOCUMENTATION.md | 6 +++--- config | 2 +- radicale/app/__init__.py | 2 +- radicale/group/__init__.py | 2 +- radicale/tests/test_group.py | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 77a33d33..9f210e9b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1026,7 +1026,7 @@ Available types are: * `ldap` _(>= 3.3.0)_ Use a LDAP or AD server to authenticate users by relaying credentials from clients and handle results. - User groups are supported. Requires group/type=`auth_type` _(>= 3.8.0)_. + User groups are supported. Requires group/type=`from_auth` _(>= 3.8.0)_. * `dovecot` _(>= 3.3.1)_ Use a Dovecot server to authenticate users by relaying credentials from clients and handle results. @@ -1265,7 +1265,7 @@ They also give you access to the group calendars, if those exist. Default: (unset) -Requires group lookup type set to `auth_type` _(>= 3.8.0)_ +Requires group lookup type set to `from_auth` _(>= 3.8.0)_ ##### ldap_group_members_attribute @@ -1513,7 +1513,7 @@ Available types are: * `none` No groups lookup at all -* `auth_type` +* `from_auth` Group lookup by authentication type (if supported) * `htgroup` diff --git a/config b/config index 01cf8046..ab212f9d 100644 --- a/config +++ b/config @@ -214,7 +214,7 @@ [group] # Group lookup method -# Value: none | auth_type | htgroup +# Value: none | from_auth | htgroup type = none # Htgroup filename diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index ab685362..f0ce6490 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -601,7 +601,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead, group_type = self.configuration.get("group", "type") if group_type in ["htgroup"]: self._rights._user_groups = self._group.groups(login) if login else set([]) - elif group_type in ["auth_type"]: + elif group_type in ["from_auth"]: auth_type = self.configuration.get("auth", "type") if auth_type in ["ldap", "pam"]: try: diff --git a/radicale/group/__init__.py b/radicale/group/__init__.py index 92b2279b..53f387c6 100644 --- a/radicale/group/__init__.py +++ b/radicale/group/__init__.py @@ -29,7 +29,7 @@ from radicale import config, utils from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("none", - "auth_type", + "from_auth", "htgroup", ) diff --git a/radicale/tests/test_group.py b/radicale/tests/test_group.py index cf4d0ee4..067996ac 100644 --- a/radicale/tests/test_group.py +++ b/radicale/tests/test_group.py @@ -107,7 +107,7 @@ class TestBaseGroupRequests(BaseTest): or "Group memberships (htgroup) for user 'tmp': {'group1', 'group2'}" in log ]) == 0 - def test_incompatible_group_auth_type(self) -> None: + def test_incompatible_group_from_auth(self) -> None: for auth_type in ["dovecot", "imap", "remote_user", "http_remote_user", "htpasswd", "oauth2"]: logging.info("\n*** test: auth_type=%r, group_type=%r", "dovecot", auth_type) try: @@ -116,7 +116,7 @@ class TestBaseGroupRequests(BaseTest): "type": auth_type, "oauth2_token": "dummy", }, - "group": {"type": "auth_type"} + "group": {"type": "from_auth"} }) except RuntimeError: pass @@ -130,7 +130,7 @@ class TestBaseGroupRequests(BaseTest): {"auth": { "type": auth_type, }, - "group": {"type": "auth_type"} + "group": {"type": "from_auth"} }) except RuntimeError: raise From 73cd0ea894f751befaf45dc4f8f299ca6a64c261 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 31 Jul 2026 17:44:59 +0200 Subject: [PATCH 23/26] sharing/group: add missing file supporting 'from_auth' --- radicale/group/from_auth.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 radicale/group/from_auth.py diff --git a/radicale/group/from_auth.py b/radicale/group/from_auth.py new file mode 100644 index 00000000..b0372d85 --- /dev/null +++ b/radicale/group/from_auth.py @@ -0,0 +1,35 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +A dummy backend that returns no group but check whether authentication type supports it. + +""" +from typing import Set + +from radicale import config, group + + +class Group(group.BaseGroup): + + def __init__(self, configuration: config.Configuration) -> None: + super().__init__(configuration) + auth_type = configuration.get("auth", "type") + if auth_type not in ["ldap", "pam"]: + raise RuntimeError("group-type 'auth_type' is not supported by auth/type %r" % auth_type) + + def _groups(self, login: str) -> Set[str]: + return set([]) From 9d545b2770576cb01bac8d76a9d4bf7dbc98a222 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 7 Aug 2026 21:58:44 +0200 Subject: [PATCH 24/26] extend changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ddb42a..2977905a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## 3.8.0.dev * Fix: storage/multifilesystem: depth:1 PROPFIND no longer re-runs the filesystem collision check (path_to_filesystem) for every item in a collection; this made listing large collections O(n^2) on file systems not detected as collision-free * Improve: storage/multifilesystem: avoid redundant stat() calls per item in get/upload when use_mtime_and_size_for_item_cache is enabled +* Feature: [sharing] add sharing-by-group/realm +* Feature: [group] with type "htgroup", "none", "from_auth" (NEW) +* Extension: [auth] type "pam": set groups of user to be used later +* Adjustment: reject usernames starting or ending with "@" or having more than one "@" +* Adjustment: reject usernames containing ":" ## 3.7.8 * Fix: time-range filter on a VTODO having DTSTART/DUE and also CREATED/COMPLETED used the CREATED->COMPLETED duration instead of the DTSTART->DUE one, so completed tasks were missing from (or wrongly returned by) calendar-query REPORT results From 60af7c81e6b304b931124411eeb28d3000c95f24 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 8 Aug 2026 06:28:34 +0200 Subject: [PATCH 25/26] group: skip test on Windows+MacOS --- radicale/tests/test_group.py | 1 + 1 file changed, 1 insertion(+) diff --git a/radicale/tests/test_group.py b/radicale/tests/test_group.py index 067996ac..8b280fad 100644 --- a/radicale/tests/test_group.py +++ b/radicale/tests/test_group.py @@ -107,6 +107,7 @@ class TestBaseGroupRequests(BaseTest): or "Group memberships (htgroup) for user 'tmp': {'group1', 'group2'}" in log ]) == 0 + @pytest.mark.skipif(sys.platform == "darwin" or sys.platform == 'win32', reason="not supported on MacOS or Windows") def test_incompatible_group_from_auth(self) -> None: for auth_type in ["dovecot", "imap", "remote_user", "http_remote_user", "htpasswd", "oauth2"]: logging.info("\n*** test: auth_type=%r, group_type=%r", "dovecot", auth_type) From 810f06c148074e322e3b81f12e19bb2c88d95270 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 8 Aug 2026 07:08:10 +0200 Subject: [PATCH 26/26] bugfix --- radicale/tests/test_group.py | 1 + 1 file changed, 1 insertion(+) diff --git a/radicale/tests/test_group.py b/radicale/tests/test_group.py index 8b280fad..ef8808ad 100644 --- a/radicale/tests/test_group.py +++ b/radicale/tests/test_group.py @@ -21,6 +21,7 @@ Radicale tests related to group lookup. import logging import os +import sys import pytest