group: add general support and for htgroup file

This commit is contained in:
Peter Bieringer
2026-07-18 15:02:31 +02:00
parent 219bd68174
commit edd20d34f2
4 changed files with 300 additions and 2 deletions

View File

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

View File

@@ -0,0 +1,71 @@
# 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/>.
"""
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)

182
radicale/group/htgroup.py Normal file
View File

@@ -0,0 +1,182 @@
# 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/>.
"""
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 <group>:<members> in line: %d" % line_num)
else:
errors += 1
logger.warning("htgroup file contains problematic line not matching <group>:<members> 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([])

30
radicale/group/none.py Normal file
View File

@@ -0,0 +1,30 @@
# 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.
"""
from typing import Set
from radicale import group
class Group(group.BaseGroup):
def _groups(self, login: str) -> Set[str]:
return set([])