Compare commits
10 Commits
ec22822b4c
...
f02ff33d82
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02ff33d82 | ||
|
|
810f06c148 | ||
|
|
60af7c81e6 | ||
|
|
9d545b2770 | ||
|
|
73cd0ea894 | ||
|
|
5abb0046e3 | ||
|
|
36c459fec8 | ||
|
|
593388e957 | ||
|
|
92d1ae9317 | ||
|
|
46c52ee703 |
@@ -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
|
||||
|
||||
@@ -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
2
config
@@ -214,7 +214,7 @@
|
||||
[group]
|
||||
|
||||
# Group lookup method
|
||||
# Value: none | auth_type | htgroup
|
||||
# Value: none | from_auth | htgroup
|
||||
type = none
|
||||
|
||||
# Htgroup filename
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -29,7 +29,7 @@ from radicale import config, utils
|
||||
from radicale.log import logger
|
||||
|
||||
INTERNAL_TYPES: Sequence[str] = ("none",
|
||||
"auth_type",
|
||||
"from_auth",
|
||||
"htgroup",
|
||||
)
|
||||
|
||||
|
||||
35
radicale/group/from_auth.py
Normal file
35
radicale/group/from_auth.py
Normal 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([])
|
||||
@@ -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([])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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=[
|
||||
|
||||
Reference in New Issue
Block a user