Compare commits

...

10 Commits

Author SHA1 Message Date
Peter Bieringer
f02ff33d82 Merge pull request #2195 from pbiering/sharing-by-group
Some checks failed
Build and publish Docker image / build-and-push-image (push) Has been cancelled
Cleanup old nightly docker images / Cleanup old nightly docker images (push) Has been cancelled
Sharing-by-group/realm
2026-08-08 08:36:23 +03:00
Peter Bieringer
810f06c148 bugfix 2026-08-08 07:08:10 +02:00
Peter Bieringer
60af7c81e6 group: skip test on Windows+MacOS 2026-08-08 06:28:34 +02:00
Peter Bieringer
9d545b2770 extend changelog 2026-08-07 21:58:44 +02:00
Peter Bieringer
73cd0ea894 sharing/group: add missing file supporting 'from_auth' 2026-08-07 21:52:01 +02:00
Peter Bieringer
5abb0046e3 sharing/group: rename auth_type -> from_auth 2026-08-07 21:52:01 +02:00
Peter Bieringer
36c459fec8 sharing: add additional test cases related to permissions 2026-08-07 21:52:01 +02:00
Peter Bieringer
593388e957 sharing/group: improve permission check 2026-08-07 21:52:01 +02:00
Peter Bieringer
92d1ae9317 rights: add 2 support functions 2026-08-07 21:52:01 +02:00
Peter Bieringer
46c52ee703 group: add pam+ldap for tests 2026-08-07 21:52:01 +02:00
12 changed files with 125 additions and 34 deletions

View File

@@ -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

View File

@@ -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`

2
config
View File

@@ -214,7 +214,7 @@
[group]
# Group lookup method
# Value: none | auth_type | htgroup
# Value: none | from_auth | htgroup
type = none
# Htgroup filename

View File

@@ -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]

View File

@@ -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:

View File

@@ -29,7 +29,7 @@ from radicale import config, utils
from radicale.log import logger
INTERNAL_TYPES: Sequence[str] = ("none",
"auth_type",
"from_auth",
"htgroup",
)

View File

@@ -0,0 +1,35 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2026-2026 Peter Bieringer <pb@bieringer.de>
#
# 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 <http://www.gnu.org/licenses/>.
"""
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([])

View File

@@ -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([])

View File

@@ -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:

View File

@@ -21,6 +21,7 @@ Radicale tests related to group lookup.
import logging
import os
import sys
import pytest
@@ -107,7 +108,8 @@ class TestBaseGroupRequests(BaseTest):
or "Group memberships (htgroup) for user 'tmp': {'group1', 'group2'}" in log
]) == 0
def test_incompatible_group_auth_type(self) -> None:
@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)
try:
@@ -116,7 +118,7 @@ class TestBaseGroupRequests(BaseTest):
"type": auth_type,
"oauth2_token": "dummy",
},
"group": {"type": "auth_type"}
"group": {"type": "from_auth"}
})
except RuntimeError:
pass
@@ -130,7 +132,7 @@ class TestBaseGroupRequests(BaseTest):
{"auth": {
"type": auth_type,
},
"group": {"type": "auth_type"}
"group": {"type": "from_auth"}
})
except RuntimeError:
raise

View File

@@ -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"

View File

@@ -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=[