From 60f1feb6193628a4ce1c4ba71e1d398ed1d7e3f3 Mon Sep 17 00:00:00 2001 From: tkeriven Date: Thu, 20 Aug 2026 15:28:16 +0200 Subject: [PATCH 1/8] OP#2270 : add API action on account From d27b971aaaca5a7aca7bd6014e5fac77aea3f6d7 Mon Sep 17 00:00:00 2001 From: tkeriven Date: Tue, 18 Aug 2026 14:23:43 +0200 Subject: [PATCH 2/8] OP#2800 : add calendars share APIs use domain preference to forbid sharing --- app/api/v1/calendar/ApiCalendar.py | 63 +++++- app/api/v1/calendar/schemas/calendar.py | 180 +++++++++++++++++- app/api/v1/mail/ApiMailFilter.py | 10 +- app/api/v1/user/ApiUserShare.py | 44 +++++ app/api/v1/user/__init__.py | 3 +- app/factory/share/RepositoryAcl.py | 140 ++++++++++++++ app/factory/share/share.py | 105 ++++++++++ app/factory/share/shareCalendar.py | 113 +++++++++++ .../calendar/InterfaceApiCalendarCalendar.py | 137 ++++++++++++- app/interface/user/InterfaceUserShare.py | 36 ++++ app/module/calendar/ModuleCalendar.py | 115 ++++++++++- app/module/calendar/acl/CalendarAclEngine.py | 35 +++- .../calendar/repository/RepositoryCalendar.py | 17 ++ app/module/calendar/source/CalendarSources.py | 61 ++++-- app/module/user/ModuleUserProfile.py | 36 ++++ app/module/user/ModuleUserShare.py | 72 +++++++ app/utils/constants.py | 1 + app/utils/errors.py | 8 + 18 files changed, 1137 insertions(+), 39 deletions(-) create mode 100644 app/api/v1/user/ApiUserShare.py create mode 100644 app/factory/share/RepositoryAcl.py create mode 100644 app/factory/share/share.py create mode 100644 app/factory/share/shareCalendar.py create mode 100644 app/interface/user/InterfaceUserShare.py create mode 100644 app/module/user/ModuleUserShare.py diff --git a/app/api/v1/calendar/ApiCalendar.py b/app/api/v1/calendar/ApiCalendar.py index bcb66a7c..10d4be9f 100644 --- a/app/api/v1/calendar/ApiCalendar.py +++ b/app/api/v1/calendar/ApiCalendar.py @@ -2,16 +2,17 @@ from typing import TYPE_CHECKING -from flask import g +from flask import g, request from flask.views import MethodView from flask.typing import ResponseReturnValue from flask_smorest import Blueprint from werkzeug.datastructures import FileStorage +from app.config.settings.DomainSettings import UserModuleSettings from app.interface.calendar.InterfaceApiCalendarCalendar import InterfaceApiCalendarCalendar from app.utils.api.ApiBaseResponse import create_api_base_response -from app.utils.api.is_async import AsyncQueryArgsSchema, async_endpoint -from app.utils.errors import ERROR_CALENDAR_IMPORT_NO_FILE +from app.utils.api.is_async import async_endpoint +from app.utils.errors import ERROR_CALENDAR_IMPORT_NO_FILE, ERROR_CALENDAR_SHARING_DISABLED from app.utils.logger.logger import logger_api from .schemas.calendar import ( CalendarCreateSchema, @@ -23,6 +24,10 @@ CalendarImportResponseSchema, CalendarImportUploadSchema, CalendarSubscriptionResponseSchema, + CalendarSharePatchSchema, + CalendarSharePutSchema, + CalendarSharePostSchema, + CalendarShareResponseSchema, ) from .schemas.event import ( AttendanceSchema, @@ -58,7 +63,14 @@ @blp.before_request -def init_calendar_config() -> None: # pylint: disable=missing-function-docstring +def init_calendar_config() -> ResponseReturnValue | None: # pylint: disable=missing-function-docstring + if request.path.endswith("/share"): + user_domain_settings: dict = g.user_domain_settings + user_module_settings: dict = user_domain_settings.get(UserModuleSettings.subparent, {}) + if "calendar" in user_module_settings.get("SOGO_D_FOLDER_DISABLE_SHARING", []): + logger_api.debug("Access denied for %s: calendar sharing is disabled", request.path) + return create_api_base_response(None, ERROR_CALENDAR_SHARING_DISABLED) + g.inter = InterfaceApiCalendarCalendar( process_setting=g.process_settings, user_domain_settings=g.user_domain_settings, @@ -382,6 +394,49 @@ def get(self, query_args: dict) -> ResponseReturnValue: return interface.get_reminders(query_args) +@blp.route("/calendars//share") +class ApiCalendarShare(MethodView): + """API to manage calendar sharing and user permissions.""" + + @blp.response(200, CalendarShareResponseSchema, example=CalendarShareResponseSchema.example()) + def get(self, key: str) -> ResponseReturnValue: + """Get all user permissions for a calendar.""" + logger_api.debug("GET /calendars/%s/share user=%s", key, g.user.uid) + interface: InterfaceApiCalendarCalendar = g.inter + return interface.get_calendar_share(key) + + @blp.arguments(CalendarSharePatchSchema(many=True), example=CalendarSharePatchSchema.example()) # type: ignore [arg-type] + @blp.response(200, CalendarShareResponseSchema, example=CalendarShareResponseSchema.example()) + def patch(self, body: list[dict], key: str) -> ResponseReturnValue: + """Partially update user permissions for a calendar. + + Only the users specified in the request body are modified. + Other existing permissions remain unchanged. + """ + logger_api.debug("PATCH /calendars/%s/share user=%s body=%s", key, g.user.uid, body) + interface: InterfaceApiCalendarCalendar = g.inter + return interface.patch_calendar_share(key, body) + + @blp.arguments(CalendarSharePutSchema(many=True), example=CalendarSharePutSchema.example()) # type: ignore [arg-type] + @blp.response(200, CalendarShareResponseSchema, example=CalendarShareResponseSchema.example()) + def put(self, body: list[dict], key: str) -> ResponseReturnValue: + """Replace all user permissions for a calendar. + + All existing permissions are replaced by the users specified in the request body. + """ + logger_api.debug("PUT /calendars/%s/share user=%s body=%s", key, g.user.uid, body) + interface: InterfaceApiCalendarCalendar = g.inter + return interface.put_calendar_share(key, body) + + @blp.arguments(CalendarSharePostSchema(many=True), example=CalendarSharePostSchema.example()) # type: ignore [arg-type] + @blp.response(200, CalendarShareResponseSchema, example=CalendarShareResponseSchema.example()) + def post(self, body: list[dict], key: str) -> ResponseReturnValue: + """Grant full modify permissions to one or several users.""" + logger_api.debug("POST /calendars/%s/share user=%s body=%s", key, g.user.uid, body) + interface: InterfaceApiCalendarCalendar = g.inter + return interface.post_calendar_share(key, body) + + @blp.route("/external-calendars") class ApiExternalCalendarList(MethodView): """API to list and create external ICS calendar subscriptions.""" diff --git a/app/api/v1/calendar/schemas/calendar.py b/app/api/v1/calendar/schemas/calendar.py index 0a1a804e..4ee38a20 100644 --- a/app/api/v1/calendar/schemas/calendar.py +++ b/app/api/v1/calendar/schemas/calendar.py @@ -1,6 +1,7 @@ from __future__ import annotations -from marshmallow import Schema, fields, validate +from typing import Any +from marshmallow import Schema, fields, validate, validates_schema, ValidationError from app.api.v1.calendar.schemas.components import CalendarPermissionsSchema from app.api.v1.calendar.schemas.event import DateTimeEndUtcField, DateTimeUtcField @@ -158,3 +159,180 @@ class CalendarImportUploadSchema(Schema): required=True, metadata={"type": "string", "format": "binary", "description": "The .ics file to import."}, ) + + +class CalendarShareRightsSchema(Schema): + """Permission rights for different event visibility levels.""" + + public = fields.String( + required=True, + validate=validate.OneOf(["view-all", "view-date-time", "respond-to", "modify", "none"]), + metadata={"description": "Permission for public events: view-all | view-date-time | respond-to | modify | none", "example": "view-all"} + ) + confidential = fields.String( + required=True, + validate=validate.OneOf(["view-all", "view-date-time", "respond-to", "modify", "none"]), + metadata={"description": "Permission for confidential events: view-all | view-date-time | respond-to | modify | none", "example": "view-date-time"} + ) + private = fields.String( + required=True, + validate=validate.OneOf(["view-all", "view-date-time", "respond-to", "modify", "none"]), + metadata={"description": "Permission for private events: view-all | view-date-time | respond-to | modify | none", "example": "none"} + ) + can_create_objects = fields.Boolean(required=True, metadata={"description": "Can create new events", "example": True}) + can_erase_objects = fields.Boolean(required=True, metadata={"description": "Can delete events", "example": False}) + + +class CalendarShareUserSchema(Schema): + """User permission entry in calendar sharing. + + ``c_email`` and ``uid`` are required unless ``user_class`` is ``"anyone"``, in which case + they are ignored (the share applies to any authenticated user, not a specific one). + """ + + c_email = fields.String(required=False, allow_none=True, metadata={"description": "User email address", "example": "jdoe@example.org"}) + uid = fields.String(required=False, allow_none=True, metadata={"description": "User UID", "example": "jdoe"}) + user_class = fields.String( + required=True, + validate=validate.OneOf(["user", "anyone"]), + ) + rights = fields.Nested(CalendarShareRightsSchema, required=True, metadata={"description": "Permission rights for this user"}) + + @validates_schema + def validate_user_identity(self, data: dict[str, Any], **kwargs: Any) -> None: # pylint: disable=unused-argument + """Require c_email and uid unless user_class is 'anyone'.""" + if data.get("user_class") == "anyone": + return + errors: dict[str, list[str]] = {} + if not data.get("c_email"): + errors["c_email"] = ["Missing data for required field."] + if not data.get("uid"): + errors["uid"] = ["Missing data for required field."] + if errors: + raise ValidationError(errors) + +class CalendarSharePatchSchema(CalendarShareUserSchema): + """Request body item for PATCH /calendars/{key}/share - partial update of user permissions. + + The endpoint expects a JSON list of these objects (use with ``many=True``). + Only the users specified in the request are modified. Other existing permissions remain unchanged. + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "public": "view-all", + "confidential": "view-date-time", + "private": "none", + "can_create_objects": True, + "can_erase_objects": False + } + } + ] + + +class CalendarSharePutSchema(CalendarShareUserSchema): + """Request body item for PUT /calendars/{key}/share - replace all user permissions. + + The endpoint expects a JSON list of these objects (use with ``many=True``). + All existing permissions are replaced by the users specified in the request. + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "public": "view-all", + "confidential": "view-date-time", + "private": "none", + "can_create_objects": True, + "can_erase_objects": False + } + }, + { + "c_email": "alice@example.org", + "uid": "alice", + "user_class": "user", + "rights": { + "public": "modify", + "confidential": "modify", + "private": "view-date-time", + "can_create_objects": True, + "can_erase_objects": True + } + } + ] + + +class CalendarSharePostSchema(CalendarShareUserSchema): + """Request body item for POST /calendars/{key}/share - grant full modify permissions to users. + + The endpoint expects a JSON list of these objects (use with ``many=True``). + Grants 'modify' permission for all event types and object management rights to the specified users. + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "public": "modify", + "confidential": "modify", + "private": "modify", + "can_create_objects": True, + "can_erase_objects": True + } + } + ] + + +class CalendarShareResponseSchema(ApiBaseResponse): + """Response schema for calendar sharing endpoints. ``data`` is a plain list of users.""" + + data = fields.List(fields.Nested(CalendarShareUserSchema), allow_none=True) + + @staticmethod + def example() -> dict[str, Any]: + """Example full envelope for Swagger documentation.""" + return { + "data": [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "public": "view-all", + "confidential": "view-date-time", + "private": "none", + "can_create_objects": True, + "can_erase_objects": False + } + } + ], + "error_code": "S000000", + "error_msg": "No Error" + } diff --git a/app/api/v1/mail/ApiMailFilter.py b/app/api/v1/mail/ApiMailFilter.py index 0670eaf4..203fbde2 100644 --- a/app/api/v1/mail/ApiMailFilter.py +++ b/app/api/v1/mail/ApiMailFilter.py @@ -1,13 +1,15 @@ from __future__ import annotations from typing import TYPE_CHECKING -from flask import abort, g, request +from flask import g, request from flask.views import MethodView from flask.typing import ResponseReturnValue from flask_smorest import Blueprint from app.config.settings.DomainSettings import MailSettings from app.interface.mail.InterfaceApiMailFilter import InterfaceApiMailFilter +from app.utils.api.ApiBaseResponse import create_api_base_response +from app.utils.errors import ERROR_MAIL_FILTERING_DISABLED, ERROR_MAIL_FILTER_FEATURE_DISABLED from app.utils.logger.logger import logger_api from .schemas.filter import ( FiltersPayloadSchema, @@ -29,7 +31,7 @@ @blp.before_request -def init_filter_config() -> None: +def init_filter_config() -> ResponseReturnValue | None: """Initialize the filter interface for the request.""" logger_api.debug("Calling before_request for ApiMailFilter") process: ProcessSetting = g.process_settings @@ -40,7 +42,7 @@ def init_filter_config() -> None: if not mail_settings.get("SOGO_D_MAIL_FILTERING_ENABLED", True): - abort(403) + return create_api_base_response(None, ERROR_MAIL_FILTERING_DISABLED) _ROUTE_SETTING_MAP = { "/vacation": "SOGO_D_VACATION_ENABLED", @@ -54,7 +56,7 @@ def init_filter_config() -> None: logger_api.debug( "Access denied for %s: %s is False", request.path, setting_key ) - abort(403) + return create_api_base_response(None, ERROR_MAIL_FILTER_FEATURE_DISABLED) break g.inter = InterfaceApiMailFilter( diff --git a/app/api/v1/user/ApiUserShare.py b/app/api/v1/user/ApiUserShare.py new file mode 100644 index 00000000..49e11075 --- /dev/null +++ b/app/api/v1/user/ApiUserShare.py @@ -0,0 +1,44 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from flask import g +from flask.views import MethodView +from flask.typing import ResponseReturnValue +from flask_smorest import Blueprint + +from app.interface.user.InterfaceUserShare import InterfaceUserShare +from app.utils.logger.logger import logger_api + +if TYPE_CHECKING: + from app.config.settings.ProcessSetting import ProcessSetting + from app.auth.User import User + + +blp = Blueprint("Share", __name__, url_prefix="/share") + + +@blp.before_request +def init_user_share() -> None: + """ + Init the interface and others if needed + """ + logger_api.debug("Calling before_request for ApiUserShare") + process: ProcessSetting = g.process_settings + user_domain: dict = g.user_domain_settings + user: User = g.user + interface_api = InterfaceUserShare(process_settings=process, user_domain=user_domain, user=user) + g.inter = interface_api + + +@blp.route("") +class ApiUserShare(MethodView): + """ + Return user's shared folders (calendars and addressbooks) + """ + @blp.response(200) + def get(self) -> ResponseReturnValue: + """ + Get user's folders structure + """ + interface_api: InterfaceUserShare = g.inter + return interface_api.get_user_share() diff --git a/app/api/v1/user/__init__.py b/app/api/v1/user/__init__.py index 737c85b3..b7a67826 100644 --- a/app/api/v1/user/__init__.py +++ b/app/api/v1/user/__init__.py @@ -2,5 +2,6 @@ from .ApiUserPreferences import blp as user_preference_api from .ApiUserProfile import blp as user_profile_api +from .ApiUserShare import blp as user_share_api -user_profile_apis : list[Blueprint] = [user_profile_api, user_preference_api] +user_profile_apis : list[Blueprint] = [user_profile_api, user_preference_api, user_share_api] diff --git a/app/factory/share/RepositoryAcl.py b/app/factory/share/RepositoryAcl.py new file mode 100644 index 00000000..e4c72898 --- /dev/null +++ b/app/factory/share/RepositoryAcl.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.config.db import tables as tbl +from app.utils.db.Condition import AndCondition, EqualCondition +from app.utils.exceptions import BugException + +if TYPE_CHECKING: + from app.manager.db.ClientSQL import ClientSQL + + +# Columns in ALL_ACL_COL order (used for SELECT and row mapping) +_ALL_COLS: tuple[str, ...] = tuple(col.name for col in tbl.ALL_ACL_COL) +# Columns for INSERT - id is serial, omitted +_INSERT_COLS: tuple[str, ...] = tuple(col.name for col in tbl.ALL_ACL_COL if col.name != tbl.COL_ID.name) + + +class AclEntry: # pylint: disable=too-few-public-methods + """One row of sogo6_acl: the rights a single user has on a single resource.""" + + def __init__(self, resource_type: str, key: str, owner: str, to_user: str, rights: dict) -> None: + self.resource_type = resource_type + self.key = key + self.owner = owner + self.to_user = to_user + self.rights = rights + + +class RepositoryAcl: + """Handles all DB reads and writes for sogo6_acl. + + Generic across resource types (calendar, addressbook, mail folder, ...): the caller always + passes the ``resource_type`` discriminant (see :class:`app.factory.share.share.Share`). + """ + + def __init__(self, db: ClientSQL) -> None: + self._db = db + + @staticmethod + def _row_to_entry(row: tuple) -> AclEntry: + d = dict(zip(_ALL_COLS, row)) + return AclEntry( + resource_type=d["type"], + key=d["key"], + owner=d["owner"], + to_user=d["to_user"], + rights=d["rights"] or {}, + ) + + def find_all_for_key(self, resource_type: str, key: str) -> list[AclEntry]: + """Return every ACL entry (one per to_user) granted on a given resource.""" + condition = AndCondition( + EqualCondition(tbl.COL_ACL_TYPE.name, resource_type), + EqualCondition(tbl.COL_ACL_KEY.name, key), + ) + rows = self._db.select_from_table( + table_name=tbl.TABLE_ACL.name, + column_tuple=_ALL_COLS, + condition=condition, + ) + return [self._row_to_entry(row) for row in rows] + + def find_one(self, resource_type: str, key: str, to_user: str) -> AclEntry | None: + """Return the ACL entry for a single (resource, to_user) pair, or None.""" + condition = AndCondition( + AndCondition( + EqualCondition(tbl.COL_ACL_TYPE.name, resource_type), + EqualCondition(tbl.COL_ACL_KEY.name, key), + ), + EqualCondition(tbl.COL_ACL_TO_USER.name, to_user), + ) + rows = list(self._db.select_from_table( + table_name=tbl.TABLE_ACL.name, + column_tuple=_ALL_COLS, + condition=condition, + limit=1, + )) + if not rows: + return None + return self._row_to_entry(rows[0]) + + def find_all_for_to_user(self, resource_type: str, to_user: str) -> list[AclEntry]: + """Return every resource key shared with to_user, for a given resource type.""" + condition = AndCondition( + EqualCondition(tbl.COL_ACL_TYPE.name, resource_type), + EqualCondition(tbl.COL_ACL_TO_USER.name, to_user), + ) + rows = self._db.select_from_table( + table_name=tbl.TABLE_ACL.name, + column_tuple=_ALL_COLS, + condition=condition, + ) + return [self._row_to_entry(row) for row in rows] + + def upsert(self, entry: AclEntry) -> None: + """Insert a new ACL entry, or update its rights if one already exists for (type, key, to_user).""" + existing: AclEntry | None = self.find_one(entry.resource_type, entry.key, entry.to_user) + if existing is None: + self._db.insert_in_table( + table_name=tbl.TABLE_ACL.name, + column_tuple=_INSERT_COLS, + values_tuple=[[entry.resource_type, entry.key, entry.owner, entry.to_user, entry.rights]], + ) + return + + condition = AndCondition( + AndCondition( + EqualCondition(tbl.COL_ACL_TYPE.name, entry.resource_type), + EqualCondition(tbl.COL_ACL_KEY.name, entry.key), + ), + EqualCondition(tbl.COL_ACL_TO_USER.name, entry.to_user), + ) + updated = self._db.update_in_table( + table_name=tbl.TABLE_ACL.name, + column_tuple=(tbl.COL_ACL_RIGHTS.name,), + values_list=[entry.rights], + condition=condition, + ) + if updated == 0: + raise BugException("RepositoryAcl.upsert: update matched 0 rows after existence check") + + def delete(self, resource_type: str, key: str, to_user: str) -> int: + """Physically delete a single ACL entry. Returns the number of rows deleted (0 or 1).""" + condition = AndCondition( + AndCondition( + EqualCondition(tbl.COL_ACL_TYPE.name, resource_type), + EqualCondition(tbl.COL_ACL_KEY.name, key), + ), + EqualCondition(tbl.COL_ACL_TO_USER.name, to_user), + ) + return self._db.delete_row_in_table(table_name=tbl.TABLE_ACL.name, condition=condition) + + def delete_all_for_key(self, resource_type: str, key: str) -> None: + """Delete every ACL entry for a resource (used when the resource itself is deleted).""" + condition = AndCondition( + EqualCondition(tbl.COL_ACL_TYPE.name, resource_type), + EqualCondition(tbl.COL_ACL_KEY.name, key), + ) + self._db.delete_row_in_table(table_name=tbl.TABLE_ACL.name, condition=condition) diff --git a/app/factory/share/share.py b/app/factory/share/share.py new file mode 100644 index 00000000..408614de --- /dev/null +++ b/app/factory/share/share.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +from app.factory.share.RepositoryAcl import AclEntry, RepositoryAcl +from app.utils import errors as err +from app.utils.exceptions import RequestException + +if TYPE_CHECKING: + from app.manager.db.ClientSQL import ClientSQL + + +class Share(ABC): + """Base class for all resource sharing (calendars, addressbooks, mail folders, ...). + + Backed by the single, decentralized ``sogo6_acl`` table (see + ``app.config.db.tables.TABLE_ACL``): one row per ``(resource_type, key, to_user)``, storing + that user's rights as a JSON blob whose shape is defined by each concrete subclass. + + Concrete subclasses (one per shareable resource type) must: + - set the ``resource_type`` class attribute (the discriminant stored in the "type" column) + - implement ``_rights_satisfy`` to interpret their own rights blob + """ + + #: Discriminant stored in the "type" column of sogo6_acl - must be set by subclasses. + resource_type: str + + def __init__(self, db: ClientSQL) -> None: + self._repo: RepositoryAcl = RepositoryAcl(db) + + @abstractmethod + def _rights_satisfy(self, rights: dict, rights_needed: Any) -> bool: + """Return True if the stored ``rights`` blob satisfies ``rights_needed``. + + ``rights_needed`` shape is defined by the subclass (e.g. a CalendarPermissionAction for + calendars). Left abstract because each resource type has its own permission model. + """ + + def check_permissions(self, for_user: str, on_key: str, rights_needed: Any) -> bool: + """Return True if for_user has rights_needed on the resource identified by on_key. + + A missing ACL entry (resource never shared with for_user) always denies. + + :param for_user: uid of the user whose access is being checked. + :param on_key: opaque key of the shared resource. + :param rights_needed: resource-specific description of the required access (see the + concrete subclass' ``_rights_satisfy`` for its shape). + """ + entry: AclEntry | None = self._repo.find_one(self.resource_type, on_key, for_user) + if entry is None: + return False + return self._rights_satisfy(entry.rights, rights_needed) + + def get_permissions(self, on_key: str) -> list[AclEntry]: + """Return every ACL entry (one per user) granted on the resource identified by on_key.""" + return self._repo.find_all_for_key(self.resource_type, on_key) + + def get_entry(self, for_user: str, on_key: str) -> AclEntry | None: + """Return the single ACL entry for (for_user, on_key), or None if never shared.""" + return self._repo.find_one(self.resource_type, on_key, for_user) + + def get_keys_shared_with(self, for_user: str) -> list[AclEntry]: + """Return every ACL entry (one per resource) granted to for_user, across all resources. + + Used to resolve the resources shared *with* a user (as opposed to get_permissions, which + resolves the users a given resource is shared *with*). + """ + return self._repo.find_all_for_to_user(self.resource_type, for_user) + + def add_permissions(self, for_user: str, on_key: str, owner: str, rights: dict) -> None: + """Grant (or overwrite) for_user's rights on the resource identified by on_key. + + :param for_user: uid of the user receiving the rights. + :param on_key: opaque key of the shared resource. + :param owner: uid of the resource owner, stored alongside the entry for reverse lookups. + :param rights: resource-specific rights blob (see the concrete subclass documentation). + :raises RequestException: ERROR_SHARE_CANNOT_SHARE_WITH_SELF when for_user == owner. + """ + if for_user == owner: + raise RequestException(error=err.ERROR_SHARE_CANNOT_SHARE_WITH_SELF) + self._repo.upsert(AclEntry(resource_type=self.resource_type, key=on_key, owner=owner, to_user=for_user, rights=rights)) + + def update_permissions(self, for_user: str, on_key: str, rights: dict) -> None: + """Update for_user's existing rights on the resource identified by on_key. + + :raises RequestException: ERROR_SHARE_NOT_FOUND when for_user has no existing entry + (use add_permissions to create the first grant). + """ + existing: AclEntry | None = self._repo.find_one(self.resource_type, on_key, for_user) + if existing is None: + raise RequestException(error=err.ERROR_SHARE_NOT_FOUND) + existing.rights = rights + self._repo.upsert(existing) + + def remove_permissions(self, for_user: str, on_key: str) -> None: + """Revoke for_user's access to the resource identified by on_key. No-op if absent.""" + self._repo.delete(self.resource_type, on_key, for_user) + + def remove_all_permissions_for_key(self, on_key: str) -> None: + """Revoke every user's access to the resource identified by on_key. + + Used when the shared resource itself is deleted, to clean up its sogo6_acl rows. + """ + self._repo.delete_all_for_key(self.resource_type, on_key) diff --git a/app/factory/share/shareCalendar.py b/app/factory/share/shareCalendar.py new file mode 100644 index 00000000..1cf0fc01 --- /dev/null +++ b/app/factory/share/shareCalendar.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.factory.share.share import Share +from app.module.calendar.model.CalendarPermissions import CalendarPermissions +from app.module.calendar.model.enums.CalendarPermissionAction import CalendarPermissionAction +from app.module.calendar.model.enums.CalendarShareLevel import CalendarShareLevel +from app.module.calendar.model.enums.EventVisibility import EventVisibility +from app.utils import constants as cs +from app.utils.strings import get_domain_from_mail + +if TYPE_CHECKING: + from app.factory.share.RepositoryAcl import AclEntry + +# Discriminant stored in sogo6_acl.type for calendar shares. +CALENDAR_RESOURCE_TYPE: str = "calendar" + +# API-facing share level strings (see CalendarShareRightsSchema) <-> internal CalendarShareLevel. +# MODIFY_IF_ORG is never exposed through the sharing API - it can only be reached by the +# CalendarAclEngine stub today (not settable by a user), so no API string maps to it. +_LEVEL_TO_STR: dict[CalendarShareLevel, str] = { + CalendarShareLevel.NONE: "none", + CalendarShareLevel.VIEW_DATETIME: "view-date-time", + CalendarShareLevel.VIEW_ALL: "view-all", + CalendarShareLevel.RESPOND: "respond-to", + CalendarShareLevel.MODIFY: "modify", +} +_STR_TO_LEVEL: dict[str, CalendarShareLevel] = {v: k for k, v in _LEVEL_TO_STR.items()} + +# Rights blob granted by POST /calendars/{key}/share (full modify access, per the endpoint's contract). +FULL_MODIFY_RIGHTS: dict = { + "public": _LEVEL_TO_STR[CalendarShareLevel.MODIFY], + "confidential": _LEVEL_TO_STR[CalendarShareLevel.MODIFY], + "private": _LEVEL_TO_STR[CalendarShareLevel.MODIFY], + "can_create_objects": True, + "can_erase_objects": True, +} + + +class ShareCalendar(Share): + """Sharing for calendars, backed by sogo6_acl (type='calendar'). + + The rights blob stored per (calendar key, to_user) matches the API's CalendarShareRightsSchema: + ``{"public": , "confidential": , "private": , + "can_create_objects": bool, "can_erase_objects": bool}`` where ```` is one of + "none" | "view-date-time" | "view-all" | "respond-to" | "modify". + + ``rights_needed`` passed to ``check_permissions`` is either: + - a bare ``CalendarPermissionAction.CREATE`` / ``CalendarPermissionAction.DELETE`` + (checked against the calendar-wide ``can_create_objects`` / ``can_erase_objects`` flags), or + - a ``(CalendarPermissionAction, EventVisibility)`` tuple for VIEW / RESPOND / MODIFY, checked + against the level of the matching visibility class. + """ + + resource_type: str = CALENDAR_RESOURCE_TYPE + + def get_user_or_anyone(self, for_user_uid: str, owner_uid: str, on_key: str) -> AclEntry | None: + """Resolve the ACL entry granting for_user_uid access to on_key. + + Priority: an entry addressed specifically to for_user_uid; failing that, the "anyone" + pseudo entry (``cs.ANYONE_TO_USER``, "") - but only when for_user_uid and + owner_uid belong to the same mail domain, since an "anyone" share only ever means + "anyone in the owner's domain". + """ + entry: AclEntry | None = self.get_entry(for_user_uid, on_key) + if entry is not None: + return entry + user_domain: str | None = get_domain_from_mail(for_user_uid) + owner_domain: str | None = get_domain_from_mail(owner_uid) + if not user_domain or user_domain != owner_domain: + return None + return self.get_entry(cs.ANYONE_TO_USER, on_key) + + @staticmethod + def level_for_visibility(rights: dict, visibility: EventVisibility) -> CalendarShareLevel: + """Return the CalendarShareLevel granted for a given event visibility class.""" + key: str = { + EventVisibility.CONFIDENTIAL: "confidential", + EventVisibility.PRIVATE: "private", + }.get(visibility, "public") + return _STR_TO_LEVEL.get(rights.get(key, "none"), CalendarShareLevel.NONE) + + @staticmethod + def to_calendar_permissions(rights: dict) -> CalendarPermissions: + """Convert a stored rights blob into a CalendarPermissions, for CalendarAclEngine.""" + return CalendarPermissions( + public_level=ShareCalendar.level_for_visibility(rights, EventVisibility.PUBLIC), + confidential_level=ShareCalendar.level_for_visibility(rights, EventVisibility.CONFIDENTIAL), + private_level=ShareCalendar.level_for_visibility(rights, EventVisibility.PRIVATE), + can_create=bool(rights.get("can_create_objects", False)), + can_delete=bool(rights.get("can_erase_objects", False)), + ) + + def _rights_satisfy(self, rights: dict, rights_needed: CalendarPermissionAction | tuple[CalendarPermissionAction, EventVisibility]) -> bool: + if isinstance(rights_needed, tuple): + action, visibility = rights_needed + else: + action, visibility = rights_needed, EventVisibility.PUBLIC + + if action == CalendarPermissionAction.CREATE: + return bool(rights.get("can_create_objects", False)) + if action == CalendarPermissionAction.DELETE: + return bool(rights.get("can_erase_objects", False)) + + level: CalendarShareLevel = self.level_for_visibility(rights, visibility) + if action == CalendarPermissionAction.VIEW: + return level >= CalendarShareLevel.VIEW_DATETIME + if action == CalendarPermissionAction.RESPOND: + return level >= CalendarShareLevel.RESPOND + if action == CalendarPermissionAction.MODIFY: + return level >= CalendarShareLevel.MODIFY + return False diff --git a/app/interface/calendar/InterfaceApiCalendarCalendar.py b/app/interface/calendar/InterfaceApiCalendarCalendar.py index e7c00bd3..844f7da0 100644 --- a/app/interface/calendar/InterfaceApiCalendarCalendar.py +++ b/app/interface/calendar/InterfaceApiCalendarCalendar.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import replace from datetime import datetime, timezone from typing import TYPE_CHECKING, Any @@ -11,7 +12,9 @@ ) from app.config.settings.UserSettings import UserCalendarGeneralSettings, UserGeneralSettings from app.module.admin.ModuleAdminConfig import ModuleAdminConfig +from app.module.auth.ModuleUserSource import ModuleUserSource from app.module.calendar.ModuleCalendar import ModuleCalendar +from app.factory.share.RepositoryAcl import AclEntry from app.module.calendar.imip.ImipBuilder import ImipBuilder from app.module.calendar.imip.ImipEmailBuilder import ImipEmailBuilder from app.module.mail.ModuleMailOutgoing import ModuleMailOutgoing @@ -65,6 +68,7 @@ class InterfaceApiCalendarCalendar: # pylint: disable=too-many-instance-attribu def __init__(self, process_setting: ProcessSetting, user_domain_settings: dict, user: User) -> None: self.user: User = user self._process_setting: ProcessSetting = process_setting + self._user_domain_settings: dict = user_domain_settings self.settings: CalendarContactSettingsObj = CalendarContactSettingsObj(user_domain_settings[CalendarContactSettings.subparent]) self.module: ModuleCalendar = ModuleCalendar(process_setting, cache=sogo_cache(), agent=sogo_agent()) # iMIP is sent through the mail module: cross-module collaboration lives in the interface. @@ -90,7 +94,7 @@ def _calendar_user_from_owner_uid(self, owner_uid: str) -> CalendarUser: We need the owner's email. The uid is not necessarily the email - it has to be resolved through the user module (ModuleUserProfile). The architecture rule forbids a module calling another module, so this resolution cannot live in ModuleCalendar; it stays here in the - interface, which is why the caller pays a second lookup (the calendar module looks the + interface, which is why the caller pays a second lookup (the calendar/event looks the calendar/event up again to operate on it). When the owner is the acting user (personal calendar) we skip the profile fetch entirely - it would be pointless. """ @@ -200,7 +204,9 @@ def update_calendar(self, key: str, body: dict[str, Any]) -> tuple[dict[str, Any def delete_calendar(self, key: str) -> tuple[dict[str, Any], int]: """Delete a calendar.""" try: - self.module.delete_calendar(self.user, key) + shared_uids: list[str] = self.module.delete_calendar(self.user, key) + for shared_uid in shared_uids: + self._user_module.remove_folder_key(shared_uid, "CALENDAR", key, owner_key="SUBS") return create_api_base_response(None) except RequestException as ex: logger_api.error("delete_calendar failed for user %s key %s: %s", self.user.uid, key, ex) @@ -647,3 +653,130 @@ def _calendar_settings_by_uid(self, user_uid: str) -> CalendarContactSettingsObj domain: str = get_domain_from_mail(user_uid) or "" raw: dict = config_module.get_one_domain_setting(domain)["settings"] return CalendarContactSettingsObj(raw[CalendarContactSettings.subparent]) + + # + # Calendar sharing + # + def get_calendar_share(self, key: str) -> tuple[dict[str, Any], int]: + """Get all user permissions for a calendar. + + :param key: Calendar key. + :return: API envelope with list of users and their permission levels. + """ + try: + entries: list[AclEntry] = self.module.get_calendar_share(self.user, key) + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("get_calendar_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + def patch_calendar_share(self, key: str, body: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Partially update user permissions for a calendar. + + Only the users specified in the request body are modified. + Other existing permissions remain unchanged. + + :param key: Calendar key. + :param body: List of users (uid and rights) to update. + :return: API envelope with updated user permissions. + """ + try: + users: list[dict[str, Any]] = [{"uid": self._resolve_to_user(entry), "rights": entry["rights"]} for entry in body] + entries: list[AclEntry] = self.module.patch_calendar_share(self.user, key, users) + self._grant_folder_subs_keys([u["uid"] for u in users], key) + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("patch_calendar_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + def put_calendar_share(self, key: str, body: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Replace all user permissions for a calendar. + + All existing permissions are replaced by the users specified in the request body. + + :param key: Calendar key. + :param body: List of users (uid and rights) that becomes the full set of shares. + :return: API envelope with new user permissions. + """ + try: + previous_uids: set[str] = {entry.to_user for entry in self.module.get_calendar_share(self.user, key)} + users: list[dict[str, Any]] = [{"uid": self._resolve_to_user(entry), "rights": entry["rights"]} for entry in body] + entries: list[AclEntry] = self.module.put_calendar_share(self.user, key, users) + new_uids: set[str] = {u["uid"] for u in users} + self._grant_folder_subs_keys(new_uids, key) + for revoked_uid in previous_uids - new_uids: + if revoked_uid == cs.ANYONE_TO_USER: + continue + self._user_module.remove_folder_key(revoked_uid, "CALENDAR", key, owner_key="SUBS") + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("put_calendar_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + def post_calendar_share(self, key: str, body: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Grant full modify permissions to one or several users. + + :param key: Calendar key. + :param body: List of users (UIDs) to grant full permissions to. + :return: API envelope with updated user permissions. + """ + try: + target_uids: list[str] = [self._resolve_to_user(entry) for entry in body] + entries: list[AclEntry] = self.module.grant_calendar_share(self.user, key, target_uids) + self._grant_folder_subs_keys(target_uids, key) + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("post_calendar_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + def _resolve_to_user(self, entry: dict[str, Any]) -> str: + """Resolve the ACL to_user for a share entry. + + A "anyone" user_class always collapses to the SOGo pseudo-user "" in + sogo6_acl.to_user, regardless of whatever uid the caller may have supplied. + """ + if entry.get("user_class") == cs.USER_CLASS_ANY: + return cs.ANYONE_TO_USER + return entry["uid"] + + def _grant_folder_subs_keys(self, target_uids: Iterable[str], key: str) -> None: + """Add ``key`` to folders.CALENDAR.SUBS for each target uid so it surfaces in their webmail. + + Cross-module orchestration (ModuleCalendar + ModuleUserProfile) is intentionally kept in + this interface layer, since a module must never call another module directly. The + "anyone" pseudo-user has no real folders to update, so it is skipped. + """ + for target_uid in target_uids: + if target_uid == cs.ANYONE_TO_USER: + continue + self._user_module.add_folder_key(target_uid, "CALENDAR", key, owner_key="SUBS") + + def _serialize_share_entries(self, entries: list[AclEntry]) -> list[dict[str, Any]]: + """Resolve each ACL entry's to_user into the API's CalendarShareUserSchema shape. + + A to_user not known by any user source is still returned (user_class ANY) so the caller + can see the raw grant instead of silently losing it. The "" pseudo to_user is + the "anyone" share and is never resolved through the user source. + """ + module_us: ModuleUserSource | None = None + result: list[dict[str, Any]] = [] + for entry in entries: + if entry.to_user == cs.ANYONE_TO_USER: + result.append({ + "c_email": "", + "uid": "", + "user_class": cs.USER_CLASS_ANY, + "rights": entry.rights, + }) + continue + if module_us is None: + module_us = ModuleUserSource.init_from_domain_settings(self._user_domain_settings) + target: User = User(uid=entry.to_user) + module_us.get_contact_info_for_user(target) + result.append({ + "c_email": target.uid, #TODO provisoire pour l'UI, target.mail if not target.anonymous else "", #TODO : return empty string for unknown users? + "uid": entry.to_user, + "user_class": cs.USER_CLASS_ANON if target.anonymous else "", #TODO : quand on aura user sources? on mettra le user_class de la source, sinon on mettra ANON pour les inconnus? + "rights": entry.rights, + }) + return result diff --git a/app/interface/user/InterfaceUserShare.py b/app/interface/user/InterfaceUserShare.py new file mode 100644 index 00000000..9bc5bf7d --- /dev/null +++ b/app/interface/user/InterfaceUserShare.py @@ -0,0 +1,36 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from app.module.user.ModuleUserShare import ModuleUserShare +from app.utils.api.ApiBaseResponse import create_api_base_response +from app.utils.exceptions import RequestException + +if TYPE_CHECKING: + from app.config.settings.ProcessSetting import ProcessSetting + from app.auth.User import User + + +class InterfaceUserShare: + """ + Interface for user shares (folders containing calendars and addressbooks) + """ + + def __init__(self, process_settings: ProcessSetting, user_domain: dict, user: User): + self.process_settings = process_settings + self.user = user + self.user_domain = user_domain + self.module_user_share = ModuleUserShare(process_settings, user_domain) + + def get_user_share(self) -> tuple[dict, int]: + """ + Get the user's folders (calendars and addressbooks) + + :return: Tuple containing response dict and HTTP status code + :rtype: tuple[dict, int] + """ + try: + folders = self.module_user_share.get_user_folders(self.user.uid) + except RequestException as ex: + return create_api_base_response(None, ex.error) + + return create_api_base_response(folders) diff --git a/app/module/calendar/ModuleCalendar.py b/app/module/calendar/ModuleCalendar.py index 574bfad7..44c3d242 100644 --- a/app/module/calendar/ModuleCalendar.py +++ b/app/module/calendar/ModuleCalendar.py @@ -16,6 +16,8 @@ from app.module.calendar.imip.ImipParser import ImipParser from app.module.calendar.imip.ImipProcessor import ImipProcessor from app.module.calendar.acl.CalendarAclEngine import CalendarAclEngine +from app.factory.share.RepositoryAcl import AclEntry +from app.factory.share.shareCalendar import FULL_MODIFY_RIGHTS, ShareCalendar from app.module.calendar.model.CalCalendar import CalCalendar from app.module.calendar.model.CalendarPermissions import CalendarPermissions from app.module.calendar.model.CalendarUser import CalendarUser @@ -74,9 +76,10 @@ def __init__( self._db.connect() self._cache: ClientRedis | None = cache self._agent: ClientAgent | None = agent - self._sources: CalendarSources = CalendarSources(self._db) + self._share: ShareCalendar = ShareCalendar(self._db) + self._sources: CalendarSources = CalendarSources(self._db, share=self._share) self._imip: ImipProcessor = ImipProcessor(self._sources) - self._acl: CalendarAclEngine = CalendarAclEngine() + self._acl: CalendarAclEngine = CalendarAclEngine(share=self._share) def __del__(self) -> None: if hasattr(self, "_db"): @@ -130,11 +133,16 @@ def get_all_calendars(self, user: User, shared_keys: list[str] | None = None) -> return calendars def get_calendar(self, user: User, key: str) -> CalendarSource: - """Return the source for a calendar, or raise NOT_FOUND. Populates permissions.""" - calendar_user: CalendarUser = CalendarUser(user=user, owner=user) + """Return the source for a calendar, or raise NOT_FOUND. Populates permissions. + + calendar_user.owner is the calendar's actual owner (not necessarily ``user``): a shared + calendar keeps its own owner uid so CalendarAclEngine can tell an owner access from a + shared one and resolve the acting user's real permissions. + """ source: CalendarSource | None = self._sources.get_by_key(user.uid, key) if source is None: raise RequestException(error=err.ERROR_CALENDAR_NOT_FOUND) + calendar_user: CalendarUser = CalendarUser(user=user, owner=User(uid=source.calendar.user_uid)) source.calendar.permissions = self._acl.get_permissions(source.calendar, calendar_user) return source @@ -161,17 +169,107 @@ def update_calendar(self, user: User, key: str, calendar: CalCalendar) -> CalCal source.update_calendar(calendar) return calendar - def delete_calendar(self, user: User, key: str) -> None: - """Delete a calendar and all its events.""" + def delete_calendar(self, user: User, key: str) -> list[str]: + """Delete a calendar and all its events. + + Also cleans up any sogo6_acl rows granting other users access to this calendar. + + :return: the list of uids that had a share on this calendar (so the interface layer can + clean up their folders.CALENDAR.SUBS entry too). + """ source: CalendarSource = self.get_calendar(user, key) + shared_uids: list[str] = [entry.to_user for entry in self._share.get_permissions(key)] source.delete_calendar() + self._share.remove_all_permissions_for_key(key) + return shared_uids + + # + # Calendar sharing + # + def _require_owned_calendar(self, user: User, key: str) -> CalendarSource: + """Return the calendar source, raising ACCESS_DENIED if user is not its owner. + + Sharing management (list / grant / patch / put) is an owner-only operation: get_calendar + now also resolves calendars merely shared with user, so an explicit ownership check is + required here to prevent a sharee from managing the resource's ACL. + """ + source: CalendarSource = self.get_calendar(user, key) + if source.calendar.user_uid != user.uid: + raise RequestException(error=err.ERROR_CALENDAR_ACCESS_DENIED) + return source + + def get_calendar_share(self, user: User, key: str) -> list[AclEntry]: + """Return all ACL entries (one per user) granted on the calendar identified by key. + + The caller must be the owner of the calendar. + """ + self._require_owned_calendar(user, key) + return self._share.get_permissions(key) + + def grant_calendar_share(self, user: User, key: str, target_uids: list[str]) -> list[AclEntry]: + """Grant full modify permissions on the calendar to one or several users. + + :param user: the acting user, must own the calendar. + :param key: opaque key of the calendar to share. + :param target_uids: uids to grant full modify permissions to. + :raises RequestException: ERROR_CALENDAR_NOT_FOUND if the calendar does not exist; + ERROR_CALENDAR_ACCESS_DENIED if user does not own it; + ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself. + """ + source: CalendarSource = self._require_owned_calendar(user, key) + owner_uid: str = source.calendar.user_uid + for target_uid in target_uids: + self._share.add_permissions(target_uid, key, owner_uid, dict(FULL_MODIFY_RIGHTS)) + return self._share.get_permissions(key) + + def patch_calendar_share(self, user: User, key: str, users: list[dict]) -> list[AclEntry]: + """Grant or update rights for one or several users, leaving other existing shares untouched. + + :param user: the acting user, must own the calendar. + :param key: opaque key of the calendar to share. + :param users: list of ``{"uid": ..., "rights": {...}}`` entries to upsert. + :raises RequestException: ERROR_CALENDAR_NOT_FOUND if the calendar does not exist; + ERROR_CALENDAR_ACCESS_DENIED if user does not own it; + ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself. + """ + source: CalendarSource = self._require_owned_calendar(user, key) + owner_uid: str = source.calendar.user_uid + for entry in users: + self._share.add_permissions(entry["uid"], key, owner_uid, entry["rights"]) + return self._share.get_permissions(key) + + def put_calendar_share(self, user: User, key: str, users: list[dict]) -> list[AclEntry]: + """Replace all existing shares on the calendar with exactly the given users' rights. + + Any user currently shared with but absent from ``users`` is revoked. + + :param user: the acting user, must own the calendar. + :param key: opaque key of the calendar to share. + :param users: list of ``{"uid": ..., "rights": {...}}`` entries; becomes the full set of shares. + :raises RequestException: ERROR_CALENDAR_NOT_FOUND if the calendar does not exist; + ERROR_CALENDAR_ACCESS_DENIED if user does not own it; + ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself. + """ + source: CalendarSource = self._require_owned_calendar(user, key) + owner_uid: str = source.calendar.user_uid + new_uids: set[str] = {entry["uid"] for entry in users} + for existing in self._share.get_permissions(key): + if existing.to_user not in new_uids: + self._share.remove_permissions(existing.to_user, key) + for entry in users: + self._share.add_permissions(entry["uid"], key, owner_uid, entry["rights"]) + return self._share.get_permissions(key) # # Events - CRUD # def create_event(self, calendar_user: CalendarUser, calendar_key: str, event: CalEvent, organizer: CalOrganizer) -> CalEvent: """Persist a new event in the calendar and propagate it to local attendees.""" - source: CalendarSource = self.get_calendar(calendar_user.owner, calendar_key) + # get_calendar must resolve as the acting user, not the owner: passing the owner would make + # get_calendar see "owner accessing their own calendar" and grant full owner permissions, + # bypassing the acting user's actual ACL rights (see update_event/delete_event, which + # resolve permissions from the full calendar_user and don't have this issue). + source: CalendarSource = self.get_calendar(calendar_user.user, calendar_key) self._acl.check_permission(source.calendar.permissions, CalendarPermissionAction.CREATE) calendar: CalCalendar = source.calendar event.apply_defaults( @@ -380,7 +478,8 @@ def process_imip_cancel(self, calendar_user: CalendarUser, ical_bytes: bytes, fr # def create_task(self, calendar_user: CalendarUser, calendar_key: str, task: CalEvent) -> CalEvent: """Persist a new VTODO in the calendar and return it.""" - source: CalendarSource = self.get_calendar(calendar_user.owner, calendar_key) + # See create_event: resolve as the acting user, not the owner, or the ACL check is bypassed. + source: CalendarSource = self.get_calendar(calendar_user.user, calendar_key) self._acl.check_permission(source.calendar.permissions, CalendarPermissionAction.CREATE) # Mark it a task before defaulting so the calendar default duration never forces a due date. task.component_type = ComponentType.TASK diff --git a/app/module/calendar/acl/CalendarAclEngine.py b/app/module/calendar/acl/CalendarAclEngine.py index ed88f55b..073a71e9 100644 --- a/app/module/calendar/acl/CalendarAclEngine.py +++ b/app/module/calendar/acl/CalendarAclEngine.py @@ -11,6 +11,8 @@ from app.utils.exceptions import BugException, RequestException if TYPE_CHECKING: + from app.factory.share.RepositoryAcl import AclEntry + from app.factory.share.shareCalendar import ShareCalendar from app.module.calendar.model.CalCalendar import CalCalendar from app.module.calendar.model.CalEvent import CalEvent from app.module.calendar.model.CalendarUser import CalendarUser @@ -22,14 +24,20 @@ class CalendarAclEngine: """Resolves and enforces calendar permissions. Centralizes all ACL logic: permission resolution, action checks, and event sanitization. - Currently stubbed - owner gets full access, non-owner is denied. - Will be connected to the ACL module when it is implemented. + Owner gets full access; a non-owner's permissions are resolved from the sogo6_acl-backed + ``ShareCalendar`` when one is supplied, denied otherwise (e.g. legacy/unit-test callers that + construct the engine without a share resolver). """ + def __init__(self, share: ShareCalendar | None = None) -> None: + self._share: ShareCalendar | None = share + def get_permissions(self, calendar: CalCalendar, calendar_user: CalendarUser) -> CalendarPermissions: """Resolve the permissions for a user on a specific calendar. - Owner gets full access on local calendars. Non-owner is denied (stub). + Owner gets full access on local calendars. Non-owner's permissions come from the + sogo6_acl entry granted on this calendar (see ShareCalendar), or denied when none exists + or no share resolver was supplied. ICS calendars can be shared with overridden permissions, but events are never writable: levels are capped at VIEW_ALL and create/modify are always denied. """ @@ -44,13 +52,26 @@ def get_permissions(self, calendar: CalCalendar, calendar_user: CalendarUser) -> can_delete=False, ) else: - # TODO: lookup shared permissions from the ACL module, then cap below - base = CalendarPermissions.denied() + base = self._resolve_shared_permissions(calendar, calendar_user) return self._cap_ics_permissions(base) if is_owner: return CalendarPermissions.owner() - # TODO: lookup real permissions from the ACL module - return CalendarPermissions.denied() + return self._resolve_shared_permissions(calendar, calendar_user) + + def _resolve_shared_permissions(self, calendar: CalCalendar, calendar_user: CalendarUser) -> CalendarPermissions: + """Look up calendar_user.user's sogo6_acl entry on this calendar, or deny if none. + + Falls back to the "anyone" share ("") when calendar_user.user and the calendar + owner share the same mail domain - see ShareCalendar.get_user_or_anyone. + """ + if self._share is None or calendar.key is None: + return CalendarPermissions.denied() + entry: AclEntry | None = self._share.get_user_or_anyone( + calendar_user.user.uid, calendar_user.owner.uid, calendar.key, + ) + if entry is None: + return CalendarPermissions.denied() + return self._share.to_calendar_permissions(entry.rights) def check_permission(self, permissions: CalendarPermissions | None, action: CalendarPermissionAction, event: CalEvent | None = None, calendar_user: CalendarUser | None = None) -> None: diff --git a/app/module/calendar/repository/RepositoryCalendar.py b/app/module/calendar/repository/RepositoryCalendar.py index bc355de6..91d4d72c 100644 --- a/app/module/calendar/repository/RepositoryCalendar.py +++ b/app/module/calendar/repository/RepositoryCalendar.py @@ -129,6 +129,23 @@ def find_by_key(self, user_uid: str, key: str) -> CalCalendar | None: return None return self._row_to_calendar(rows[0]) + def find_by_key_only(self, key: str) -> CalCalendar | None: + """Return the calendar matching key, regardless of owner. + + Unlike find_by_key, not scoped to a user_uid: used by the sharing feature, where the + caller (a prospective sharee, or the share management module) does not necessarily own + the calendar. The key itself (an opaque generated uuid) is the lookup capability. + """ + rows = list(self._db.select_from_table( + table_name=tbl.TABLE_CALENDAR.name, + column_tuple=_ALL_COLS, + condition=EqualCondition(tbl.COL_CAL_KEY.name, key), + limit=1, + )) + if not rows: + return None + return self._row_to_calendar(rows[0]) + def find_by_share_token(self, share_token: str) -> CalCalendar | None: """Return the calendar matching the public subscription token, or None. diff --git a/app/module/calendar/source/CalendarSources.py b/app/module/calendar/source/CalendarSources.py index dc8c6a7d..46dcaa7d 100644 --- a/app/module/calendar/source/CalendarSources.py +++ b/app/module/calendar/source/CalendarSources.py @@ -12,11 +12,14 @@ from app.module.calendar.rrule.RecurrenceScopeProcessor import EventAction, ScopeResult from app.module.calendar.source.CalendarSourceDb import CalendarSourceDb from app.module.calendar.source.CalendarSourceIcsMirror import CalendarSourceIcsMirror +from app.utils import constants as cs from app.utils import errors as err from app.utils.exceptions import RequestException from app.utils.logger.logger import logger_calendar +from app.utils.strings import get_domain_from_mail if TYPE_CHECKING: + from app.factory.share.shareCalendar import ShareCalendar from app.manager.db.ClientSQL import ClientSQL from app.module.calendar.source.CalendarSource import CalendarSource @@ -30,9 +33,10 @@ class CalendarSources: operate across calendars rather than on a single resolved source. """ - def __init__(self, db: ClientSQL) -> None: + def __init__(self, db: ClientSQL, share: ShareCalendar | None = None) -> None: self._db = db self._repo_calendar = RepositoryCalendar(db) + self._share: ShareCalendar | None = share def get(self, calendar: CalCalendar) -> CalendarSource: """Return the appropriate CalendarSource for the given calendar. @@ -50,17 +54,41 @@ def get(self, calendar: CalCalendar) -> CalendarSource: logger_calendar.error("Unknown source_type=%s for calendar key=%s", calendar.source_type, calendar.key) raise RequestException(error=err.ERROR_CALENDAR_NOT_SUPPORTED) - def get_all(self, user_uid: str) -> list[CalendarSource]: - """Return a source for every calendar owned by user_uid. - - TODO(ACL module): this is the single scope chokepoint for resolution, operations and - listings. Today it returns only calendars OWNED by user_uid. When calendar sharing lands, - it must also surface calendars SHARED WITH user_uid (own + shared, read from - sogo_calendar_shares) - that one change activates delegated access everywhere downstream - (owner resolution, event lookups, get_all_events/get_all_tasks), with per-calendar permissions then - enforced by CalendarAclEngine. + def _get_shared_calendars(self, user_uid: str) -> list[CalCalendar]: + """Return every calendar shared with user_uid, directly or via an "anyone" share. + + Directly: sogo6_acl entries where to_user=user_uid. Via "anyone": sogo6_acl entries where + to_user="", restricted to calendars whose owner shares user_uid's mail domain + (see ShareCalendar.get_user_or_anyone). Skips entries whose key no longer resolves to a + calendar (deleted resource, stale ACL row). """ - return [self.get(cal) for cal in self._repo_calendar.find_all(user_uid)] + if self._share is None: + return [] + shared: list[CalCalendar] = [] + seen_keys: set[str] = set() + for entry in self._share.get_keys_shared_with(user_uid): + cal: CalCalendar | None = self._repo_calendar.find_by_key_only(entry.key) + if cal is not None and cal.key not in seen_keys: + shared.append(cal) + seen_keys.add(cal.key) + user_domain: str | None = get_domain_from_mail(user_uid) + if user_domain: + for entry in self._share.get_keys_shared_with(cs.ANYONE_TO_USER): + if entry.key in seen_keys: + continue + cal = self._repo_calendar.find_by_key_only(entry.key) + if (cal is not None and cal.key not in seen_keys + and cal.user_uid != user_uid + and get_domain_from_mail(cal.user_uid) == user_domain): + shared.append(cal) + seen_keys.add(cal.key) + return shared + + def get_all(self, user_uid: str) -> list[CalendarSource]: + """Return a source for every calendar owned by, or shared with, user_uid.""" + owned: list[CalCalendar] = self._repo_calendar.find_all(user_uid) + shared: list[CalCalendar] = self._get_shared_calendars(user_uid) + return [self.get(cal) for cal in owned + shared] def get_default(self, user_uid: str) -> CalendarSource | None: """Return the default writable calendar source for user_uid, or None if the user has no local calendar.""" @@ -91,8 +119,17 @@ def require_event(self, user_uid: str, event_key: str) -> tuple[CalendarSource, raise RequestException(error=err.ERROR_CALENDAR_EVENT_NOT_FOUND) def get_by_key(self, user_uid: str, key: str) -> CalendarSource | None: - """Return the source for a specific calendar, or None if not found.""" + """Return the source for a specific calendar, or None if not found. + + Resolves calendars owned by user_uid, calendars shared with user_uid directly (sogo6_acl), + and calendars shared with "anyone" when user_uid shares the owner's mail domain + (see ShareCalendar.get_user_or_anyone). + """ cal = self._repo_calendar.find_by_key(user_uid, key) + if cal is None and self._share is not None: + candidate: CalCalendar | None = self._repo_calendar.find_by_key_only(key) + if candidate is not None and self._share.get_user_or_anyone(user_uid, candidate.user_uid, key) is not None: + cal = candidate return self.get(cal) if cal is not None else None def get_by_share_token(self, share_token: str) -> CalendarSource | None: diff --git a/app/module/user/ModuleUserProfile.py b/app/module/user/ModuleUserProfile.py index 96446a8e..3028a7d2 100644 --- a/app/module/user/ModuleUserProfile.py +++ b/app/module/user/ModuleUserProfile.py @@ -247,6 +247,42 @@ def add_folder_key(self, uid: str, folder_type: str, key: str, owner_key: str = # Update the database self._update_user_column(uid, tbl.COL_USER_FOLDERS.name, current_folders) + def remove_folder_key(self, uid: str, folder_type: str, key: str, owner_key: str = "OWNER") -> None: + """ + Remove a calendar or addressbook key from the folders column of a user profile. + + Symmetric counterpart of :meth:`add_folder_key`. This is a no-op (besides a debug log) if + the folders column, folder_type, owner_key, or key don't exist. + + :param uid: User unique identifier + :type uid: str + :param folder_type: Type of folder - "CALENDAR" or "ADDRESSBOOKS" + :type folder_type: str + :param key: Key of the calendar or addressbook to remove + :type key: str + :param owner_key: Owner section key - "OWNER" for personal, "EXT"/"SUBS" for external/shared, etc. + :type owner_key: str + :raises RequestException: If user profile not found + :raises AggravatedException: If multiple user profiles found or update fails + """ + logger_user_profile.debug("Removing folder key for uid: %s, folder_type: %s, owner_key: %s, key: %s", + uid, folder_type, owner_key, key) + + current_folders = self._get_user_column(uid, tbl.COL_USER_FOLDERS.name) + + if not current_folders: + return + + if folder_type not in current_folders or owner_key not in current_folders[folder_type]: + return + + if key not in current_folders[folder_type][owner_key]: + return + + del current_folders[folder_type][owner_key][key] + + self._update_user_column(uid, tbl.COL_USER_FOLDERS.name, current_folders) + def _get_user_column(self, uid: str, field_name: str) -> Any: """ Generic method to get a specific field from user profile diff --git a/app/module/user/ModuleUserShare.py b/app/module/user/ModuleUserShare.py new file mode 100644 index 00000000..6122d6a7 --- /dev/null +++ b/app/module/user/ModuleUserShare.py @@ -0,0 +1,72 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from app.config.db import tables as tbl +from app.utils import errors as err +from app.utils.db.Condition import EqualCondition +from app.utils.exceptions import RequestException, AggravatedException +from app.utils.logger.logger import logger_user_profile +from app.utils.module.importManager import import_and_instantiate_manager + +if TYPE_CHECKING: + from app.config.settings.ProcessSetting import ProcessSetting + from app.manager.db.ClientSQL import ClientSQL + + +class ModuleUserShare: + """ + Module to handle user folders/shares (calendar and addressbook) in sogo_user_profiles table + """ + + def __init__(self, process_settings: ProcessSetting, domain_settings: dict): + """ + Initialize the module with database connection + + :param process_settings: Process settings containing database configuration + :type process_settings: ProcessSetting + :param domain_settings: Domain settings dictionary + :type domain_settings: dict + """ + self.process_settings = process_settings + + sogo_db_type = f"Client{process_settings.SOGO_P_DB_TYPE}" + + self.sogo_db_manager: ClientSQL = import_and_instantiate_manager( + module_path="app.manager.db", + module_and_class_name=sogo_db_type, + module_args=self.process_settings.get_db_settings() + ) + + def get_user_folders(self, uid: str) -> dict: + """ + Get the folders column content for a user (contains calendar and addressbook keys) + + :param uid: User unique identifier + :type uid: str + :return: Folders dictionary containing CALENDAR and ADDRESSBOOKS structure + :rtype: dict + :raises RequestException: If user profile not found + :raises AggravatedException: If multiple user profiles found + """ + logger_user_profile.debug("Getting folders for uid: %s", uid) + + self.sogo_db_manager.connect() + + condition = EqualCondition(tbl.COL_USER_UID.name, uid) + result = list(self.sogo_db_manager.select_from_table( + table_name=tbl.TABLE_USER.name, + column_tuple=(tbl.COL_USER_FOLDERS.name,), + condition=condition + )) + + if len(result) == 0: + logger_user_profile.error("No user found for uid: %s", uid) + raise RequestException(err.ERROR_USER_PROFILE_NOT_FOUND.m, err.ERROR_USER_PROFILE_NOT_FOUND) + + if len(result) > 1: + logger_user_profile.error("Multiple users found for uid: %s", uid) + raise AggravatedException(err.ERROR_USER_PROFILE_DUPLICATE.m, err.ERROR_USER_PROFILE_DUPLICATE) + + folders = result[0][0] + logger_user_profile.debug("Successfully retrieved folders for uid: %s", uid) + return folders if folders else {} diff --git a/app/utils/constants.py b/app/utils/constants.py index 439f9a2e..aea004f0 100644 --- a/app/utils/constants.py +++ b/app/utils/constants.py @@ -30,6 +30,7 @@ USER_CLASS_RES = "ressource" #Ressource, location, room, things... USER_CLASS_ANY = "anyone" #Anyone (and anything) that can be authenticated USER_CLASS_ANON = "anonymous" +ANYONE_TO_USER = "" #SOGo convention: the pseudo to_user marking a share granted to "anyone" (any authenticated user). # Sorted set used to index user sessions by last activity timestamp. # Each member is a ``user_session:`` key and its score is the # Unix timestamp of the last activity. diff --git a/app/utils/errors.py b/app/utils/errors.py index 07563efa..2dcc50e7 100644 --- a/app/utils/errors.py +++ b/app/utils/errors.py @@ -172,6 +172,8 @@ def __init__(self, c:str, m:str, h:int = HTTPStatus.INTERNAL_SERVER_ERROR): ERROR_SIEVE_LOGOUT = E("S001507", "Sieve command issued while not connected", HTTPStatus.INTERNAL_SERVER_ERROR) ERROR_SIEVE_PUSH_FAILED = E("S001508", "Failed To Push Filters To Sieve", HTTPStatus.INTERNAL_SERVER_ERROR) ERROR_SIEVE_CAPABILITY_NOT_FOUND = E("S001509", "Sieve capability not found in server response", HTTPStatus.INTERNAL_SERVER_ERROR) +ERROR_MAIL_FILTERING_DISABLED = E("S001510", "Mail Filtering Is Disabled For This Domain", HTTPStatus.FORBIDDEN) +ERROR_MAIL_FILTER_FEATURE_DISABLED = E("S001511", "This Mail Filter Feature Is Disabled For This Domain", HTTPStatus.FORBIDDEN) #Search ERROR_MAIL_SEARCH_FAILED = E("S000338", "IMAP search command failed", HTTPStatus.INTERNAL_SERVER_ERROR) @@ -227,6 +229,7 @@ def __init__(self, c:str, m:str, h:int = HTTPStatus.INTERNAL_SERVER_ERROR): ERROR_CALENDAR_PUBLIC_LINK_DISABLED = E("S000623", "Public Calendar Link Is Disabled For This Domain", HTTPStatus.FORBIDDEN) ERROR_CALENDAR_EXPORT_FORMAT_UNSUPPORTED = E("S000624", "Requested Export Format Is Not Supported", HTTPStatus.NOT_ACCEPTABLE) ERROR_CALENDAR_IMIP_SENDER_MISMATCH = E("S000625", "iMIP Sender Is Not The Event Organizer", HTTPStatus.FORBIDDEN) +ERROR_CALENDAR_SHARING_DISABLED = E("S000626", "Calendar Sharing Is Disabled For This Domain", HTTPStatus.FORBIDDEN) #the contacts ERROR_CONTACT_JSON_PARSE_FAILED = E("S000700", "Failed To Parse Contact JSON Content", HTTPStatus.UNPROCESSABLE_ENTITY) @@ -268,5 +271,10 @@ def __init__(self, c:str, m:str, h:int = HTTPStatus.INTERNAL_SERVER_ERROR): ERROR_ADMIN_LOGIN_FAILED = E("S001000", "Admin Login Failed: Invalid Credentials", HTTPStatus.UNAUTHORIZED) ERROR_ADMIN_AUTH_NOT_CONFIG = E("S001001", "Admin Authentication Not Configured", HTTPStatus.PRECONDITION_FAILED) +#SHARE (generic resource sharing: calendars, addressbooks, mail folders - sogo6_acl) +ERROR_SHARE_NOT_FOUND = E("S001100", "Share Not Found", HTTPStatus.NOT_FOUND) +ERROR_SHARE_TARGET_USER_NOT_FOUND = E("S001101", "Target User Not Found", HTTPStatus.NOT_FOUND) +ERROR_SHARE_CANNOT_SHARE_WITH_SELF = E("S001102", "Cannot Share A Resource With Its Own Owner", HTTPStatus.BAD_REQUEST) + #the bugs ERROR_UNKOWN = E("S999999", "Undefined Error", HTTPStatus.INTERNAL_SERVER_ERROR) From ac5338fea8f89e62e27b5ab70b2a73e91abfdedc Mon Sep 17 00:00:00 2001 From: tkeriven Date: Thu, 3 Sep 2026 11:08:48 +0200 Subject: [PATCH 3/8] Add API get preferences folders --- app/api/v1/calendar/schemas/calendar.py | 8 ++- app/api/v1/calendar/schemas/components.py | 16 ++--- app/api/v1/calendar/schemas/event.py | 6 +- app/api/v1/user/ApiUserPreferences.py | 14 ++++ app/api/v1/user/ApiUserShare.py | 44 ------------ app/api/v1/user/__init__.py | 3 +- app/factory/share/shareCalendar.py | 12 ++-- .../calendar/InterfaceApiCalendarCalendar.py | 11 ++- .../user/InterfaceUserPreferences.py | 14 ++++ app/interface/user/InterfaceUserShare.py | 36 ---------- app/module/calendar/ModuleCalendar.py | 7 +- .../serializer/CalCalendarSerializerDict.py | 3 +- .../CalendarPermissionsSerializerDict.py | 23 ++++-- .../CalendarShareDeserializerDict.py | 12 ++-- app/module/user/ModuleUserProfile.py | 15 ++++ app/module/user/ModuleUserShare.py | 72 ------------------- .../modules/ROOT/pages/calendar.adoc | 4 +- .../test_InterfaceApiCalendarCalendar.py | 3 + .../test_InterfaceApiCalendarEvent.py | 2 + 19 files changed, 114 insertions(+), 191 deletions(-) delete mode 100644 app/api/v1/user/ApiUserShare.py delete mode 100644 app/interface/user/InterfaceUserShare.py delete mode 100644 app/module/user/ModuleUserShare.py diff --git a/app/api/v1/calendar/schemas/calendar.py b/app/api/v1/calendar/schemas/calendar.py index 4ee38a20..dd5b8353 100644 --- a/app/api/v1/calendar/schemas/calendar.py +++ b/app/api/v1/calendar/schemas/calendar.py @@ -3,7 +3,7 @@ from typing import Any from marshmallow import Schema, fields, validate, validates_schema, ValidationError -from app.api.v1.calendar.schemas.components import CalendarPermissionsSchema +from app.api.v1.calendar.schemas.components import CalendarRightsSchema from app.api.v1.calendar.schemas.event import DateTimeEndUtcField, DateTimeUtcField from app.module.calendar.model.enums.EventVisibility import EventVisibility from app.utils.api.ApiBaseResponse import ApiBaseResponse @@ -70,8 +70,10 @@ class CalendarSchema(Schema): default_type = fields.String(allow_none=True) # Full public subscription URL, computed server-side from the share token when active. public_url = fields.String(allow_none=True, dump_only=True) - # `dump_only`` because permissions are only available when retrieving calendar but can't be set in that way - permissions = fields.Nested(CalendarPermissionsSchema, allow_none=True, dump_only=True) + # `dump_only` because rights are only available when retrieving calendar but can't be set in that way + rights = fields.Nested(CalendarRightsSchema, allow_none=True, dump_only=True) + owner = fields.String(allow_none=True, dump_only=True, + metadata={"description": "UID of the calendar's owner (creator, resolved via sogo6_acl for shared calendars).", "example": "jdoe"}) created_at = fields.DateTime(allow_none=True) updated_at = fields.DateTime(allow_none=True) diff --git a/app/api/v1/calendar/schemas/components.py b/app/api/v1/calendar/schemas/components.py index 4d415d2c..b541d7eb 100644 --- a/app/api/v1/calendar/schemas/components.py +++ b/app/api/v1/calendar/schemas/components.py @@ -127,14 +127,14 @@ class DatesWithTzSchema(Schema): date_end_tz_calendar = fields.String(allow_none=True) -class CalendarPermissionsSchema(Schema): - """Resolved ACL permissions for the requesting user (read-only).""" - - public_level = fields.String(metadata={"description": "none | view_datetime | view_all | respond | modify"}) - confidential_level = fields.String(metadata={"description": "none | view_datetime | view_all | respond | modify"}) - private_level = fields.String(metadata={"description": "none | view_datetime | view_all | respond | modify"}) - can_create = fields.Boolean() - can_delete = fields.Boolean() +class CalendarRightsSchema(Schema): + """Resolved ACL rights of the requesting user on a calendar (read-only).""" + + public = fields.String(metadata={"description": "none | view-date-time | view-all | respond-to | modify", "example": "view-all"}) + confidential = fields.String(metadata={"description": "none | view-date-time | view-all | respond-to | modify", "example": "none"}) + private = fields.String(metadata={"description": "none | view-date-time | view-all | respond-to | modify", "example": "none"}) + can_create_objects = fields.Boolean(metadata={"example": True}) + can_erase_objects = fields.Boolean(metadata={"example": False}) class SyncConfigUpdateSchema(Schema): diff --git a/app/api/v1/calendar/schemas/event.py b/app/api/v1/calendar/schemas/event.py index 1273a227..4bf299c4 100644 --- a/app/api/v1/calendar/schemas/event.py +++ b/app/api/v1/calendar/schemas/event.py @@ -8,8 +8,8 @@ from marshmallow import Schema, ValidationError, fields, validate from app.api.v1.calendar.schemas.components import ( - AttachmentCalendarSchema, AttendeeSchema, ConferenceDataSchema, DatesWithTzSchema, EventRelationSchema, - OrganizerSchema, RecurrenceRuleSchema, ReminderSchema, + AttachmentCalendarSchema, AttendeeSchema, CalendarRightsSchema, ConferenceDataSchema, DatesWithTzSchema, + EventRelationSchema, OrganizerSchema, RecurrenceRuleSchema, ReminderSchema, ) from app.module.calendar.CalendarConst import MAX_EVENT_DESCRIPTION_LENGTH, MAX_EVENT_LOCATION_LENGTH, MAX_EVENT_TITLE_LENGTH from app.module.calendar.model.enums.EventStatus import EventStatus @@ -117,6 +117,8 @@ class CalendarEventSchema(Schema): recurrence_id = fields.String(allow_none=True) recurrence_range = fields.String(allow_none=True) dates_with_tz = fields.Nested(DatesWithTzSchema, allow_none=True) + rights = fields.Nested(CalendarRightsSchema, allow_none=True, dump_only=True, + metadata={"description": "Requesting user's rights on the event's calendar (only returned by GET /events/)."}) class CalendarEventCreateSchema(Schema): diff --git a/app/api/v1/user/ApiUserPreferences.py b/app/api/v1/user/ApiUserPreferences.py index a6d229e9..4c23bf03 100644 --- a/app/api/v1/user/ApiUserPreferences.py +++ b/app/api/v1/user/ApiUserPreferences.py @@ -58,6 +58,20 @@ def patch(self, new_data:dict)-> ResponseReturnValue: return interface_api.update_all_preferences(new_data["settings"]) +@blp.route("/folders") +class ApiUserPreferencesFolders(MethodView): + """ + Return user's shared folders (calendars and addressbooks) + """ + @blp.response(200) + def get(self) -> ResponseReturnValue: + """ + Get user's folders structure + """ + interface_api: InterfaceUserPreferences = g.inter + return interface_api.get_user_folders() + + # @blp.route("/") # class ApiUserPreferencesPart(MethodView): # """ diff --git a/app/api/v1/user/ApiUserShare.py b/app/api/v1/user/ApiUserShare.py deleted file mode 100644 index 49e11075..00000000 --- a/app/api/v1/user/ApiUserShare.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING - -from flask import g -from flask.views import MethodView -from flask.typing import ResponseReturnValue -from flask_smorest import Blueprint - -from app.interface.user.InterfaceUserShare import InterfaceUserShare -from app.utils.logger.logger import logger_api - -if TYPE_CHECKING: - from app.config.settings.ProcessSetting import ProcessSetting - from app.auth.User import User - - -blp = Blueprint("Share", __name__, url_prefix="/share") - - -@blp.before_request -def init_user_share() -> None: - """ - Init the interface and others if needed - """ - logger_api.debug("Calling before_request for ApiUserShare") - process: ProcessSetting = g.process_settings - user_domain: dict = g.user_domain_settings - user: User = g.user - interface_api = InterfaceUserShare(process_settings=process, user_domain=user_domain, user=user) - g.inter = interface_api - - -@blp.route("") -class ApiUserShare(MethodView): - """ - Return user's shared folders (calendars and addressbooks) - """ - @blp.response(200) - def get(self) -> ResponseReturnValue: - """ - Get user's folders structure - """ - interface_api: InterfaceUserShare = g.inter - return interface_api.get_user_share() diff --git a/app/api/v1/user/__init__.py b/app/api/v1/user/__init__.py index b7a67826..737c85b3 100644 --- a/app/api/v1/user/__init__.py +++ b/app/api/v1/user/__init__.py @@ -2,6 +2,5 @@ from .ApiUserPreferences import blp as user_preference_api from .ApiUserProfile import blp as user_profile_api -from .ApiUserShare import blp as user_share_api -user_profile_apis : list[Blueprint] = [user_profile_api, user_preference_api, user_share_api] +user_profile_apis : list[Blueprint] = [user_profile_api, user_preference_api] diff --git a/app/factory/share/shareCalendar.py b/app/factory/share/shareCalendar.py index 1cf0fc01..0f372f92 100644 --- a/app/factory/share/shareCalendar.py +++ b/app/factory/share/shareCalendar.py @@ -19,20 +19,20 @@ # API-facing share level strings (see CalendarShareRightsSchema) <-> internal CalendarShareLevel. # MODIFY_IF_ORG is never exposed through the sharing API - it can only be reached by the # CalendarAclEngine stub today (not settable by a user), so no API string maps to it. -_LEVEL_TO_STR: dict[CalendarShareLevel, str] = { +LEVEL_TO_STR: dict[CalendarShareLevel, str] = { CalendarShareLevel.NONE: "none", CalendarShareLevel.VIEW_DATETIME: "view-date-time", CalendarShareLevel.VIEW_ALL: "view-all", CalendarShareLevel.RESPOND: "respond-to", CalendarShareLevel.MODIFY: "modify", } -_STR_TO_LEVEL: dict[str, CalendarShareLevel] = {v: k for k, v in _LEVEL_TO_STR.items()} +STR_TO_LEVEL: dict[str, CalendarShareLevel] = {v: k for k, v in LEVEL_TO_STR.items()} # Rights blob granted by POST /calendars/{key}/share (full modify access, per the endpoint's contract). FULL_MODIFY_RIGHTS: dict = { - "public": _LEVEL_TO_STR[CalendarShareLevel.MODIFY], - "confidential": _LEVEL_TO_STR[CalendarShareLevel.MODIFY], - "private": _LEVEL_TO_STR[CalendarShareLevel.MODIFY], + "public": LEVEL_TO_STR[CalendarShareLevel.MODIFY], + "confidential": LEVEL_TO_STR[CalendarShareLevel.MODIFY], + "private": LEVEL_TO_STR[CalendarShareLevel.MODIFY], "can_create_objects": True, "can_erase_objects": True, } @@ -79,7 +79,7 @@ def level_for_visibility(rights: dict, visibility: EventVisibility) -> CalendarS EventVisibility.CONFIDENTIAL: "confidential", EventVisibility.PRIVATE: "private", }.get(visibility, "public") - return _STR_TO_LEVEL.get(rights.get(key, "none"), CalendarShareLevel.NONE) + return STR_TO_LEVEL.get(rights.get(key, "none"), CalendarShareLevel.NONE) @staticmethod def to_calendar_permissions(rights: dict) -> CalendarPermissions: diff --git a/app/interface/calendar/InterfaceApiCalendarCalendar.py b/app/interface/calendar/InterfaceApiCalendarCalendar.py index 844f7da0..b69167c3 100644 --- a/app/interface/calendar/InterfaceApiCalendarCalendar.py +++ b/app/interface/calendar/InterfaceApiCalendarCalendar.py @@ -36,6 +36,7 @@ from app.module.calendar.serializer.CalTaskDeserializerDict import CalTaskDeserializerDict from app.module.calendar.serializer.CalTaskSerializerDict import CalTaskSerializerDict from app.module.calendar.serializer.CalCalendarSerializerDict import CalCalendarSerializerDict +from app.module.calendar.serializer.CalendarPermissionsSerializerDict import CalendarPermissionsSerializerDict from app.module.calendar.serializer.CalCalendarsSerializerList import CalCalendarsSerializerList from app.module.calendar.serializer.CalEventReminderSerializerDict import CalEventReminderSerializerDict from app.module.calendar.serializer.CalFreeBusyResultSerializerDict import CalFreeBusyResultSerializerDict @@ -84,6 +85,7 @@ def __init__(self, process_setting: ProcessSetting, user_domain_settings: dict, self._calendar_deserializer: CalCalendarDeserializerDict = CalCalendarDeserializerDict() self._calendar_serializer: CalCalendarSerializerDict = CalCalendarSerializerDict() self._calendars_serializer: CalCalendarsSerializerList = CalCalendarsSerializerList() + self._permissions_serializer: CalendarPermissionsSerializerDict = CalendarPermissionsSerializerDict() self._freebusy_serializer: CalFreeBusyResultSerializerDict = CalFreeBusyResultSerializerDict() self._reminder_serializer: CalEventReminderSerializerDict = CalEventReminderSerializerDict() self._sync_status_serializer: CalSyncStatusSerializerDict = CalSyncStatusSerializerDict() @@ -256,8 +258,13 @@ def _send_imip(self, imip_msg: ImipMessage) -> None: def get_event(self, event_key: str) -> tuple[dict[str, Any], int]: """Get a single event by key.""" try: - event: CalEvent = self.module.get_event(self._event_user_for(event_key), event_key) - return create_api_base_response(self._event_serializer.serialize(event)) + calendar_user: CalendarUser = self._event_user_for(event_key) + event: CalEvent = self.module.get_event(calendar_user, event_key) + event_dict: dict[str, Any] = self._event_serializer.serialize(event) + event_dict["rights"] = self._permissions_serializer.serialize( + self.module.get_event_permissions(calendar_user, event_key), + ) + return create_api_base_response(event_dict) except RequestException as ex: logger_api.error("get_event failed for user %s event %s: %s", self.user.uid, event_key, ex) return create_api_base_response(None, ex.error) diff --git a/app/interface/user/InterfaceUserPreferences.py b/app/interface/user/InterfaceUserPreferences.py index bd25b535..0331512e 100644 --- a/app/interface/user/InterfaceUserPreferences.py +++ b/app/interface/user/InterfaceUserPreferences.py @@ -41,6 +41,20 @@ def get_all_preferences(self) -> tuple[dict, int]: return create_api_base_response(data) + def get_user_folders(self) -> tuple[dict, int]: + """ + Get the user's folders (calendars and addressbooks) + + :return: Tuple containing response dict and HTTP status code + :rtype: tuple[dict, int] + """ + try: + folders = self.module_user_profile.get_user_folders(self.user.uid) + except RequestException as ex: + return create_api_base_response(None, ex.error) + + return create_api_base_response(folders) + def get_partial_preferences(self, subparent:str) -> tuple[dict, int]: """Get partial user preferences for a specific subparent diff --git a/app/interface/user/InterfaceUserShare.py b/app/interface/user/InterfaceUserShare.py deleted file mode 100644 index 9bc5bf7d..00000000 --- a/app/interface/user/InterfaceUserShare.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING - -from app.module.user.ModuleUserShare import ModuleUserShare -from app.utils.api.ApiBaseResponse import create_api_base_response -from app.utils.exceptions import RequestException - -if TYPE_CHECKING: - from app.config.settings.ProcessSetting import ProcessSetting - from app.auth.User import User - - -class InterfaceUserShare: - """ - Interface for user shares (folders containing calendars and addressbooks) - """ - - def __init__(self, process_settings: ProcessSetting, user_domain: dict, user: User): - self.process_settings = process_settings - self.user = user - self.user_domain = user_domain - self.module_user_share = ModuleUserShare(process_settings, user_domain) - - def get_user_share(self) -> tuple[dict, int]: - """ - Get the user's folders (calendars and addressbooks) - - :return: Tuple containing response dict and HTTP status code - :rtype: tuple[dict, int] - """ - try: - folders = self.module_user_share.get_user_folders(self.user.uid) - except RequestException as ex: - return create_api_base_response(None, ex.error) - - return create_api_base_response(folders) diff --git a/app/module/calendar/ModuleCalendar.py b/app/module/calendar/ModuleCalendar.py index 44c3d242..bd437a99 100644 --- a/app/module/calendar/ModuleCalendar.py +++ b/app/module/calendar/ModuleCalendar.py @@ -116,7 +116,6 @@ def get_all_calendars(self, user: User, shared_keys: list[str] | None = None) -> :param user: The authenticated user. :param shared_keys: Additional calendar keys to include (from the ACL module). """ - calendar_user: CalendarUser = CalendarUser(user=user, owner=user) # Owned calendars sources: list[CalendarSource] = self._sources.get_all(user.uid) # Delegated calendars (shared by other users, keys provided by the ACL module) @@ -128,6 +127,7 @@ def get_all_calendars(self, user: User, shared_keys: list[str] | None = None) -> calendars: list[CalCalendar] = [] for source in sources: cal: CalCalendar = source.calendar + calendar_user: CalendarUser = CalendarUser(user=user, owner=User(uid=cal.user_uid)) cal.permissions = self._acl.get_permissions(cal, calendar_user) calendars.append(cal) return calendars @@ -298,6 +298,11 @@ def get_event(self, calendar_user: CalendarUser, event_key: str) -> CalEvent: _, event = self._sources.require_event(calendar_user.owner.uid, event_key) return event + def get_event_permissions(self, calendar_user: CalendarUser, event_key: str) -> CalendarPermissions: + """Return the acting user's permissions on the calendar holding the given event, or raise NOT_FOUND.""" + source, _ = self._sources.require_event(calendar_user.owner.uid, event_key) + return self._acl.get_permissions(source.calendar, calendar_user) + def update_event(self, calendar_user: CalendarUser, event_key: str, event_update: CalEvent, organizer: CalOrganizer) -> CalEvent: """Update an event, handling recurrence scope and attendee propagation.""" source, event = self._sources.require_event(calendar_user.owner.uid, event_key) diff --git a/app/module/calendar/serializer/CalCalendarSerializerDict.py b/app/module/calendar/serializer/CalCalendarSerializerDict.py index ab7cff0b..1bc5be9f 100644 --- a/app/module/calendar/serializer/CalCalendarSerializerDict.py +++ b/app/module/calendar/serializer/CalCalendarSerializerDict.py @@ -27,5 +27,6 @@ def serialize(self, data: CalCalendar) -> dict[str, Any]: "default_event_duration_min": data.default_event_duration_min, "default_alarm_duration_min": data.default_alarm_duration_min, "default_type": data.default_type.value if data.default_type else None, - "permissions": self._permissions_serializer.serialize(data.permissions) if data.permissions else None, + "rights": self._permissions_serializer.serialize(data.permissions) if data.permissions else None, + "owner": data.user_uid, } diff --git a/app/module/calendar/serializer/CalendarPermissionsSerializerDict.py b/app/module/calendar/serializer/CalendarPermissionsSerializerDict.py index 066ecb3c..fca74040 100644 --- a/app/module/calendar/serializer/CalendarPermissionsSerializerDict.py +++ b/app/module/calendar/serializer/CalendarPermissionsSerializerDict.py @@ -2,18 +2,29 @@ from typing import Any +from app.factory.share.shareCalendar import LEVEL_TO_STR from app.module.calendar.model.CalendarPermissions import CalendarPermissions +from app.module.calendar.model.enums.CalendarShareLevel import CalendarShareLevel from app.utils.serializer.Serializer import Serializer class CalendarPermissionsSerializerDict(Serializer[CalendarPermissions, dict[str, Any]]): - """Serializes CalendarPermissions to a dict for API responses.""" + """Serializes CalendarPermissions to the API ``rights`` dict. + + Same shape and level strings as the sharing API (CalendarShareRightsSchema), which is also + the blob stored in sogo6_acl. + """ def serialize(self, data: CalendarPermissions) -> dict[str, Any]: return { - "public_level": data.public_level.name.lower(), - "confidential_level": data.confidential_level.name.lower(), - "private_level": data.private_level.name.lower(), - "can_create": data.can_create, - "can_delete": data.can_delete, + "public": self._level(data.public_level), + "confidential": self._level(data.confidential_level), + "private": self._level(data.private_level), + "can_create_objects": data.can_create, + "can_erase_objects": data.can_delete, } + + @staticmethod + def _level(level: CalendarShareLevel) -> str: + # MODIFY_IF_ORG has no API string: outside the organizer's own events it behaves as RESPOND. + return LEVEL_TO_STR.get(level, LEVEL_TO_STR[CalendarShareLevel.RESPOND]) diff --git a/app/module/calendar/serializer/CalendarShareDeserializerDict.py b/app/module/calendar/serializer/CalendarShareDeserializerDict.py index 9ddc598e..2e77bc97 100644 --- a/app/module/calendar/serializer/CalendarShareDeserializerDict.py +++ b/app/module/calendar/serializer/CalendarShareDeserializerDict.py @@ -2,8 +2,8 @@ from typing import Any +from app.factory.share.shareCalendar import STR_TO_LEVEL from app.module.calendar.model.CalendarShare import CalendarShare -from app.module.calendar.model.enums.CalendarShareLevel import CalendarShareLevel from app.utils.serializer.Deserializer import Deserializer @@ -14,9 +14,9 @@ def deserialize(self, data: dict[str, Any]) -> CalendarShare: return CalendarShare( user_uid=data["user_uid"], calendar_key=data["calendar_key"], - public_level=CalendarShareLevel[data.get("public_level", "none").upper()], - confidential_level=CalendarShareLevel[data.get("confidential_level", "none").upper()], - private_level=CalendarShareLevel[data.get("private_level", "none").upper()], - can_create=data.get("can_create", False), - can_delete=data.get("can_delete", False), + public_level=STR_TO_LEVEL[data.get("public", "none")], + confidential_level=STR_TO_LEVEL[data.get("confidential", "none")], + private_level=STR_TO_LEVEL[data.get("private", "none")], + can_create=data.get("can_create_objects", False), + can_delete=data.get("can_erase_objects", False), ) diff --git a/app/module/user/ModuleUserProfile.py b/app/module/user/ModuleUserProfile.py index 3028a7d2..7cea50e0 100644 --- a/app/module/user/ModuleUserProfile.py +++ b/app/module/user/ModuleUserProfile.py @@ -703,6 +703,21 @@ def get_user_preferences(self, uid:str) -> dict: return self._get_user_column(uid, tbl.COL_USER_DEFAULTS.name) + def get_user_folders(self, uid: str) -> dict: + """ + Get the folders column content for a user (contains calendar and addressbook keys) + + :param uid: User unique identifier + :type uid: str + :return: Folders dictionary containing CALENDAR and ADDRESSBOOKS structure + :rtype: dict + :raises RequestException: If user profile not found + :raises AggravatedException: If multiple user profiles found + """ + logger_user_profile.debug("Getting folders for uid: %s", uid) + + return self._get_user_column(uid, tbl.COL_USER_FOLDERS.name) + def get_partial_user_preferences(self, uid:str, subparent:str) -> dict: """ Return just a part of the user preferences diff --git a/app/module/user/ModuleUserShare.py b/app/module/user/ModuleUserShare.py deleted file mode 100644 index 6122d6a7..00000000 --- a/app/module/user/ModuleUserShare.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations -from typing import TYPE_CHECKING - -from app.config.db import tables as tbl -from app.utils import errors as err -from app.utils.db.Condition import EqualCondition -from app.utils.exceptions import RequestException, AggravatedException -from app.utils.logger.logger import logger_user_profile -from app.utils.module.importManager import import_and_instantiate_manager - -if TYPE_CHECKING: - from app.config.settings.ProcessSetting import ProcessSetting - from app.manager.db.ClientSQL import ClientSQL - - -class ModuleUserShare: - """ - Module to handle user folders/shares (calendar and addressbook) in sogo_user_profiles table - """ - - def __init__(self, process_settings: ProcessSetting, domain_settings: dict): - """ - Initialize the module with database connection - - :param process_settings: Process settings containing database configuration - :type process_settings: ProcessSetting - :param domain_settings: Domain settings dictionary - :type domain_settings: dict - """ - self.process_settings = process_settings - - sogo_db_type = f"Client{process_settings.SOGO_P_DB_TYPE}" - - self.sogo_db_manager: ClientSQL = import_and_instantiate_manager( - module_path="app.manager.db", - module_and_class_name=sogo_db_type, - module_args=self.process_settings.get_db_settings() - ) - - def get_user_folders(self, uid: str) -> dict: - """ - Get the folders column content for a user (contains calendar and addressbook keys) - - :param uid: User unique identifier - :type uid: str - :return: Folders dictionary containing CALENDAR and ADDRESSBOOKS structure - :rtype: dict - :raises RequestException: If user profile not found - :raises AggravatedException: If multiple user profiles found - """ - logger_user_profile.debug("Getting folders for uid: %s", uid) - - self.sogo_db_manager.connect() - - condition = EqualCondition(tbl.COL_USER_UID.name, uid) - result = list(self.sogo_db_manager.select_from_table( - table_name=tbl.TABLE_USER.name, - column_tuple=(tbl.COL_USER_FOLDERS.name,), - condition=condition - )) - - if len(result) == 0: - logger_user_profile.error("No user found for uid: %s", uid) - raise RequestException(err.ERROR_USER_PROFILE_NOT_FOUND.m, err.ERROR_USER_PROFILE_NOT_FOUND) - - if len(result) > 1: - logger_user_profile.error("Multiple users found for uid: %s", uid) - raise AggravatedException(err.ERROR_USER_PROFILE_DUPLICATE.m, err.ERROR_USER_PROFILE_DUPLICATE) - - folders = result[0][0] - logger_user_profile.debug("Successfully retrieved folders for uid: %s", uid) - return folders if folders else {} diff --git a/docs/developer/modules/ROOT/pages/calendar.adoc b/docs/developer/modules/ROOT/pages/calendar.adoc index 1348c2a5..49691a44 100644 --- a/docs/developer/modules/ROOT/pages/calendar.adoc +++ b/docs/developer/modules/ROOT/pages/calendar.adoc @@ -252,9 +252,9 @@ Centralizes all permission logic. Three responsibilities: `CalendarPermissions` has one `CalendarShareLevel` per visibility class (public, confidential, private) plus `can_create` and `can_delete` flags. Share levels are ordered: `NONE < VIEW_DATETIME < VIEW_ALL < RESPOND < MODIFY_IF_ORG < MODIFY`. -`MODIFY_IF_ORG` is the conditional level between `RESPOND` and `MODIFY`: the user gets `MODIFY` only on events they ORGANIZE, and behaves as `RESPOND` on everything else. Levels serialise by *name* (`modify_if_org` in the API), so the numbering is internal. +`MODIFY_IF_ORG` is the conditional level between `RESPOND` and `MODIFY`: the user gets `MODIFY` only on events they ORGANIZE, and behaves as `RESPOND` on everything else. In the API the levels serialise with the same strings as the sharing API (`none`, `view-date-time`, `view-all`, `respond-to`, `modify`); `MODIFY_IF_ORG` has no API string and is exposed as `respond-to`. -Each `CalCalendar` carries a transient `permissions` field populated at read time - the API response includes the user's permissions so the UI knows which actions to enable. +Each `CalCalendar` carries a transient `permissions` field populated at read time. The API exposes it as `rights` (`public`, `confidential`, `private`, `can_create_objects`, `can_erase_objects` - the same shape as the sogo6_acl rights blob) on `GET /calendars`, `GET /calendars/` and `GET /events/` (the latter resolves the rights on the event's calendar), so the UI knows which actions to enable. == Repositories diff --git a/tests/test_interface/test_calendar/test_InterfaceApiCalendarCalendar.py b/tests/test_interface/test_calendar/test_InterfaceApiCalendarCalendar.py index e345f597..0a56d9e7 100644 --- a/tests/test_interface/test_calendar/test_InterfaceApiCalendarCalendar.py +++ b/tests/test_interface/test_calendar/test_InterfaceApiCalendarCalendar.py @@ -3,9 +3,11 @@ from app.interface.calendar.InterfaceApiCalendarCalendar import InterfaceApiCalendarCalendar from app.module.calendar.model.CalCalendar import CalCalendar +from app.module.calendar.model.enums.CalendarSourceType import CalendarSourceType from app.module.calendar.model.enums.EventVisibility import EventVisibility from app.module.calendar.serializer.CalCalendarDeserializerDict import CalCalendarDeserializerDict from app.module.calendar.serializer.CalCalendarSerializerDict import CalCalendarSerializerDict +from app.module.calendar.serializer.CalCalendarsSerializerList import CalCalendarsSerializerList from app.utils import errors as err from app.utils.exceptions import RequestException @@ -20,6 +22,7 @@ def _build_interface(user_tz="Europe/Paris"): inter.module.create_calendar.side_effect = lambda user, cal: cal inter._calendar_deserializer = CalCalendarDeserializerDict() inter._calendar_serializer = CalCalendarSerializerDict() + inter._calendars_serializer = CalCalendarsSerializerList() inter._process_setting = MagicMock(SOGO_P_PUBLIC_BASE_URL="") inter._user_module = MagicMock() inter._user_module.get_partial_user_preferences.return_value = {"USER_GENERAL": {"SOGO_U_TIMEZONE": user_tz}} diff --git a/tests/test_interface/test_calendar/test_InterfaceApiCalendarEvent.py b/tests/test_interface/test_calendar/test_InterfaceApiCalendarEvent.py index 0f0f02c8..ec50229e 100644 --- a/tests/test_interface/test_calendar/test_InterfaceApiCalendarEvent.py +++ b/tests/test_interface/test_calendar/test_InterfaceApiCalendarEvent.py @@ -14,6 +14,7 @@ from app.module.calendar.serializer.CalEventDeserializerDict import CalEventDeserializerDict from app.module.calendar.serializer.CalEventSerializerDict import CalEventSerializerDict from app.module.calendar.serializer.CalEventsSerializerDict import CalEventsSerializerDict +from app.module.calendar.serializer.CalendarPermissionsSerializerDict import CalendarPermissionsSerializerDict from app.utils import errors as err from app.utils.exceptions import RequestException @@ -50,6 +51,7 @@ def _build_interface(module=None): inter._event_serializer = CalEventSerializerDict() inter._event_deserializer = CalEventDeserializerDict() inter._events_serializer = CalEventsSerializerDict() + inter._permissions_serializer = CalendarPermissionsSerializerDict() return inter From 948ff7842bd368bf00f66ac3cf7b4d6a185ea5de Mon Sep 17 00:00:00 2001 From: tkeriven Date: Mon, 24 Aug 2026 10:00:07 +0200 Subject: [PATCH 4/8] OP#2801 : add addressbooks share APIs --- app/api/v1/contact/ApiContact.py | 59 ++++++- app/api/v1/contact/schemas/addressbook.py | 167 +++++++++++++++++- app/api/v1/contact/schemas/contact.py | 3 + app/factory/share/shareContact.py | 69 ++++++++ .../calendar/InterfaceApiCalendarCalendar.py | 2 +- .../contact/InterfaceApiContactContact.py | 134 +++++++++++++- app/module/contact/ModuleContact.py | 113 +++++++++++- app/module/contact/acl/ContactAclEngine.py | 25 ++- .../repository/RepositoryAddressBook.py | 17 ++ .../CardAddressBookSerializerDict.py | 1 + app/module/contact/source/ContactSources.py | 51 +++++- app/utils/errors.py | 1 + .../test_AddressBookSerializerDict.py | 1 + tests/test_contact/test_ContactSources.py | 1 + 14 files changed, 621 insertions(+), 23 deletions(-) create mode 100644 app/factory/share/shareContact.py diff --git a/app/api/v1/contact/ApiContact.py b/app/api/v1/contact/ApiContact.py index 75cca9cb..3b0ce83d 100644 --- a/app/api/v1/contact/ApiContact.py +++ b/app/api/v1/contact/ApiContact.py @@ -7,12 +7,13 @@ from flask.typing import ResponseReturnValue from flask_smorest import Blueprint +from app.config.settings.DomainSettings import UserModuleSettings from app.interface.contact.InterfaceApiContactContact import InterfaceApiContactContact from app.module.contact.ContactConst import IMPORT_MAX_BYTES from app.module.contact.source.ContactSourceDb import LIST_SORTABLE_COLUMNS, SORTABLE_COLUMNS from app.utils.api.ApiBaseResponse import create_api_base_response from app.utils.api.paginate_sort_filter import collection_paginate, CustomPaginateResponse -from app.utils.errors import ERROR_CONTACT_IMPORT_NO_FILE, ERROR_CONTACT_IMPORT_TOO_LARGE +from app.utils.errors import ERROR_CONTACT_IMPORT_NO_FILE, ERROR_CONTACT_IMPORT_TOO_LARGE, ERROR_CONTACT_SHARING_DISABLED from app.utils.logger.logger import logger_api from .schemas.addressbook import ( AddressBookCreateSchema, @@ -22,6 +23,10 @@ ContactImportQueryArgsSchema, ContactImportUploadSchema, ContactJobResponseSchema, + ContactSharePatchSchema, + ContactSharePutSchema, + ContactSharePostSchema, + ContactShareResponseSchema, ) from .schemas.contact import ( ContactCreateSchema, @@ -67,7 +72,14 @@ @blp.before_request -def init_contact_config() -> None: # pylint: disable=missing-function-docstring +def init_contact_config() -> ResponseReturnValue | None: # pylint: disable=missing-function-docstring + if request.path.endswith("/share"): + user_domain_settings: dict = g.user_domain_settings + user_module_settings: dict = user_domain_settings.get(UserModuleSettings.subparent, {}) + if "contact" in user_module_settings.get("SOGO_D_FOLDER_DISABLE_SHARING", []): + logger_api.debug("Access denied for %s: contact sharing is disabled", request.path) + return create_api_base_response(None, ERROR_CONTACT_SHARING_DISABLED) + g.inter = InterfaceApiContactContact( process_setting=g.process_settings, user_domain_settings=g.user_domain_settings, @@ -122,6 +134,49 @@ def delete(self, key: str) -> ResponseReturnValue: return interface.delete_addressbook(key) +@blp.route("/addressbooks//share") +class ApiAddressBookShare(MethodView): + """API to manage address book sharing and user permissions.""" + + @blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example()) + def get(self, key: str) -> ResponseReturnValue: + """Get all user permissions for an address book.""" + logger_api.debug("GET /addressbooks/%s/share user=%s", key, g.user.uid) + interface: InterfaceApiContactContact = g.inter + return interface.get_addressbook_share(key) + + @blp.arguments(ContactSharePatchSchema(many=True), example=ContactSharePatchSchema.example()) # type: ignore [arg-type] + @blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example()) + def patch(self, body: list[dict], key: str) -> ResponseReturnValue: + """Partially update user permissions for an address book. + + Only the users specified in the request body are modified. + Other existing permissions remain unchanged. + """ + logger_api.debug("PATCH /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body) + interface: InterfaceApiContactContact = g.inter + return interface.patch_addressbook_share(key, body) + + @blp.arguments(ContactSharePutSchema(many=True), example=ContactSharePutSchema.example()) # type: ignore [arg-type] + @blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example()) + def put(self, body: list[dict], key: str) -> ResponseReturnValue: + """Replace all user permissions for an address book. + + All existing permissions are replaced by the users specified in the request body. + """ + logger_api.debug("PUT /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body) + interface: InterfaceApiContactContact = g.inter + return interface.put_addressbook_share(key, body) + + @blp.arguments(ContactSharePostSchema(many=True), example=ContactSharePostSchema.example()) # type: ignore [arg-type] + @blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example()) + def post(self, body: list[dict], key: str) -> ResponseReturnValue: + """Grant full permissions to one or several users.""" + logger_api.debug("POST /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body) + interface: InterfaceApiContactContact = g.inter + return interface.post_addressbook_share(key, body) + + @blp.route("/addressbooks//contacts") class ApiAddressBookContactList(MethodView): """API to list (paginated) and create contacts within one address book.""" diff --git a/app/api/v1/contact/schemas/addressbook.py b/app/api/v1/contact/schemas/addressbook.py index d981adba..64435a3b 100644 --- a/app/api/v1/contact/schemas/addressbook.py +++ b/app/api/v1/contact/schemas/addressbook.py @@ -1,6 +1,8 @@ from __future__ import annotations -from marshmallow import Schema, fields, validate +from typing import Any + +from marshmallow import Schema, fields, validate, validates_schema, ValidationError from app.utils.api.ApiBaseResponse import ApiBaseResponse @@ -31,6 +33,8 @@ class AddressBookSchema(Schema): is_default = fields.Boolean() source_type = fields.String() ctag = fields.Integer(metadata={"description": "CardDAV change tag, bumped on every contact mutation."}) + owner = fields.String(allow_none=True, dump_only=True, + metadata={"description": "UID of the address book's owner (creator, resolved via sogo6_acl for shared address books).", "example": "jdoe"}) class AddressBookListDataSchema(Schema): @@ -83,3 +87,164 @@ class ContactImportUploadSchema(Schema): metadata={"type": "string", "format": "binary", "description": "The JSON (.json), vCard (.vcf) or LDIF (.ldif) file to import."}, ) + + +class ContactShareRightsSchema(Schema): + """Permission rights for an address book share.""" + + can_view = fields.Boolean(required=True, metadata={"description": "Can view contacts and lists", "example": True}) + can_create_objects = fields.Boolean(required=True, metadata={"description": "Can create contacts and lists", "example": True}) + can_edit_objects = fields.Boolean(required=True, metadata={"description": "Can edit contacts and lists", "example": True}) + can_erase_objects = fields.Boolean(required=True, metadata={"description": "Can delete contacts and lists", "example": False}) + + +class ContactShareUserSchema(Schema): + """User permission entry in address book sharing. + + ``c_email`` and ``uid`` are required unless ``user_class`` is ``"anyone"``, in which case + they are ignored (the share applies to any authenticated user, not a specific one). + """ + + c_email = fields.String(required=False, allow_none=True, metadata={"description": "User email address", "example": "jdoe@example.org"}) + uid = fields.String(required=False, allow_none=True, metadata={"description": "User UID", "example": "jdoe"}) + user_class = fields.String( + required=True, + validate=validate.OneOf(["user", "anyone"]), + ) + rights = fields.Nested(ContactShareRightsSchema, required=True, metadata={"description": "Permission rights for this user"}) + + @validates_schema + def validate_user_identity(self, data: dict[str, Any], **kwargs: Any) -> None: # pylint: disable=unused-argument + """Require c_email and uid unless user_class is 'anyone'.""" + if data.get("user_class") == "anyone": + return + errors: dict[str, list[str]] = {} + if not data.get("c_email"): + errors["c_email"] = ["Missing data for required field."] + if not data.get("uid"): + errors["uid"] = ["Missing data for required field."] + if errors: + raise ValidationError(errors) + + +class ContactSharePatchSchema(ContactShareUserSchema): + """Request body item for PATCH /addressbooks/{key}/share - partial update of user permissions. + + The endpoint expects a JSON list of these objects (use with ``many=True``). + Only the users specified in the request are modified. Other existing permissions remain unchanged. + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": False + } + } + ] + + +class ContactSharePutSchema(ContactShareUserSchema): + """Request body item for PUT /addressbooks/{key}/share - replace all user permissions. + + The endpoint expects a JSON list of these objects (use with ``many=True``). + All existing permissions are replaced by the users specified in the request. + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": False + } + }, + { + "c_email": "alice@example.org", + "uid": "alice", + "user_class": "user", + "rights": { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": True + } + } + ] + + +class ContactSharePostSchema(ContactShareUserSchema): + """Request body item for POST /addressbooks/{key}/share - grant full permissions to users. + + The endpoint expects a JSON list of these objects (use with ``many=True``). + Grants full view/create/edit/erase rights to the specified users, regardless of the rights + carried in the request body. + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": True + } + } + ] + + +class ContactShareResponseSchema(ApiBaseResponse): + """Response schema for address book sharing endpoints. ``data`` is a plain list of users.""" + + data = fields.List(fields.Nested(ContactShareUserSchema), allow_none=True) + + @staticmethod + def example() -> dict[str, Any]: + """Example full envelope for Swagger documentation.""" + return { + "data": [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": False + } + } + ], + "error_code": "S000000", + "error_msg": "No Error" + } diff --git a/app/api/v1/contact/schemas/contact.py b/app/api/v1/contact/schemas/contact.py index ba0ee74a..25c4bf47 100644 --- a/app/api/v1/contact/schemas/contact.py +++ b/app/api/v1/contact/schemas/contact.py @@ -10,6 +10,7 @@ ContactPhoneSchema, ContactUrlSchema, ) +from app.api.v1.contact.schemas.addressbook import ContactShareRightsSchema from app.module.contact.model.enums.CardKind import CardKind from app.utils.api.ApiBaseResponse import ApiBaseResponse @@ -134,6 +135,8 @@ class ContactListDataSchema(Schema): """Data payload for the contact list response. The total count is in the X-Pagination header.""" contacts = fields.List(fields.Nested(ContactSchema)) + rights = fields.Nested(ContactShareRightsSchema, metadata={ + "description": "Current user's permissions on the address book. Only present when listing a single address book."}) class ContactListResponseSchema(ApiBaseResponse): diff --git a/app/factory/share/shareContact.py b/app/factory/share/shareContact.py new file mode 100644 index 00000000..3ed14026 --- /dev/null +++ b/app/factory/share/shareContact.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.factory.share.share import Share +from app.module.contact.model.enums.ContactShareLevel import ContactShareLevel +from app.utils import constants as cs +from app.utils.strings import get_domain_from_mail + +if TYPE_CHECKING: + from app.factory.share.RepositoryAcl import AclEntry + +# Discriminant stored in sogo6_acl.type for address book shares. +CONTACT_RESOURCE_TYPE: str = "addressbook" + +# Rights blob granted by POST /addressbooks/{key}/share (full access, per the endpoint's contract). +FULL_MODIFY_RIGHTS: dict = { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": True, +} + + +class ShareContact(Share): + """Sharing for address books, backed by sogo6_acl (type='addressbook'). + + The rights blob stored per (addressbook key, to_user) matches the API's + ContactShareRightsSchema: ``{"can_view": bool, "can_create_objects": bool, + "can_edit_objects": bool, "can_erase_objects": bool}``. + + ``rights_needed`` passed to ``check_permissions`` is the name of the right to check + (e.g. "can_view", "can_edit_objects"). + """ + + resource_type: str = CONTACT_RESOURCE_TYPE + + def get_user_or_anyone(self, for_user_uid: str, owner_uid: str, on_key: str) -> AclEntry | None: + """Resolve the ACL entry granting for_user_uid access to on_key. + + Priority: an entry addressed specifically to for_user_uid; failing that, the "anyone" + pseudo entry (``cs.ANYONE_TO_USER``, "") - but only when for_user_uid and + owner_uid belong to the same mail domain, since an "anyone" share only ever means + "anyone in the owner's domain". + """ + entry: AclEntry | None = self.get_entry(for_user_uid, on_key) + if entry is not None: + return entry + user_domain: str | None = get_domain_from_mail(for_user_uid) + owner_domain: str | None = get_domain_from_mail(owner_uid) + if not user_domain or user_domain != owner_domain: + return None + return self.get_entry(cs.ANYONE_TO_USER, on_key) + + @staticmethod + def to_share_level(rights: dict) -> ContactShareLevel | None: + """Convert a stored rights blob into a ContactShareLevel, for ContactAclEngine. + + Any write flag (create/edit/erase) grants MODIFY (which also satisfies a VIEW check); + otherwise can_view alone grants VIEW; a rights blob granting nothing at all denies. + """ + if rights.get("can_create_objects") or rights.get("can_edit_objects") or rights.get("can_erase_objects"): + return ContactShareLevel.MODIFY + if rights.get("can_view"): + return ContactShareLevel.VIEW + return None + + def _rights_satisfy(self, rights: dict, rights_needed: str) -> bool: + return bool(rights.get(rights_needed, False)) diff --git a/app/interface/calendar/InterfaceApiCalendarCalendar.py b/app/interface/calendar/InterfaceApiCalendarCalendar.py index b69167c3..1e66bade 100644 --- a/app/interface/calendar/InterfaceApiCalendarCalendar.py +++ b/app/interface/calendar/InterfaceApiCalendarCalendar.py @@ -755,7 +755,7 @@ def _grant_folder_subs_keys(self, target_uids: Iterable[str], key: str) -> None: """ for target_uid in target_uids: if target_uid == cs.ANYONE_TO_USER: - continue + continue # The "anyone" pseudo-user has no real folders to update, so skip it. self._user_module.add_folder_key(target_uid, "CALENDAR", key, owner_key="SUBS") def _serialize_share_entries(self, entries: list[AclEntry]) -> list[dict[str, Any]]: diff --git a/app/interface/contact/InterfaceApiContactContact.py b/app/interface/contact/InterfaceApiContactContact.py index 7740275c..7f89e4c4 100644 --- a/app/interface/contact/InterfaceApiContactContact.py +++ b/app/interface/contact/InterfaceApiContactContact.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterable from typing import TYPE_CHECKING, Any from app.config.settings.DomainSettings import ( @@ -8,6 +9,8 @@ UserModuleSettings, UserModuleSettingsObj, ) +from app.factory.share.RepositoryAcl import AclEntry +from app.module.auth.ModuleUserSource import ModuleUserSource from app.module.contact.ContactConst import AUTOCOMPLETE_DEFAULT_LIMIT from app.module.contact.ModuleContact import ModuleContact from app.module.contact.jobs.ContactJobKind import ContactJobKind @@ -35,6 +38,7 @@ from app.utils.exceptions import RequestException from app.auth.User import User from app.utils.logger.logger import logger_api +from app.utils import constants as cs if TYPE_CHECKING: from app.config.settings.ProcessSetting import ProcessSetting @@ -53,6 +57,7 @@ class InterfaceApiContactContact: # pylint: disable=too-many-instance-attribute def __init__(self, process_setting: ProcessSetting, user_domain_settings: dict, user: User) -> None: self.user: User = user self._process_setting: ProcessSetting = process_setting + self._user_domain_settings: dict = user_domain_settings self.settings: CalendarContactSettingsObj = CalendarContactSettingsObj( user_domain_settings[CalendarContactSettings.subparent] ) @@ -126,12 +131,131 @@ def update_addressbook(self, key: str, body: dict[str, Any]) -> tuple[dict[str, def delete_addressbook(self, key: str) -> tuple[dict[str, Any], int]: """Delete an address book and all its contacts.""" try: - self.module.delete_addressbook(self.user, key) + shared_uids: list[str] = self.module.delete_addressbook(self.user, key) + for shared_uid in shared_uids: + self._user_module.remove_folder_key(shared_uid, "ADDRESSBOOKS", key, owner_key="SUBS") return create_api_base_response(None) except RequestException as ex: logger_api.error("delete_addressbook failed for user %s key %s: %s", self.user.uid, key, ex) return create_api_base_response(None, ex.error) + # + # Address book sharing + # + def get_addressbook_share(self, key: str) -> tuple[dict[str, Any], int]: + """Get all user permissions for an address book. + + :param key: Address book key. + :return: API envelope with list of users and their permission levels. + """ + try: + entries: list[AclEntry] = self.module.get_addressbook_share(self.user, key) + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("get_addressbook_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + def patch_addressbook_share(self, key: str, body: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Partially update user permissions for an address book. + + Only the users specified in the request body are modified. + Other existing permissions remain unchanged. + + :param key: Address book key. + :param body: List of users (uid and rights) to update. + :return: API envelope with updated user permissions. + """ + try: + users: list[dict[str, Any]] = [{"uid": self._resolve_to_user(entry), "rights": entry["rights"]} for entry in body] + entries: list[AclEntry] = self.module.patch_addressbook_share(self.user, key, users) + self._grant_folder_subs_keys([u["uid"] for u in users], key) + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("patch_addressbook_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + def put_addressbook_share(self, key: str, body: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Replace all user permissions for an address book. + + All existing permissions are replaced by the users specified in the request body. + + :param key: Address book key. + :param body: List of users (uid and rights) that becomes the full set of shares. + :return: API envelope with new user permissions. + """ + try: + previous_uids: set[str] = {entry.to_user for entry in self.module.get_addressbook_share(self.user, key)} + users: list[dict[str, Any]] = [{"uid": self._resolve_to_user(entry), "rights": entry["rights"]} for entry in body] + entries: list[AclEntry] = self.module.put_addressbook_share(self.user, key, users) + new_uids: set[str] = {u["uid"] for u in users} + self._grant_folder_subs_keys(new_uids, key) + for revoked_uid in previous_uids - new_uids: + if revoked_uid == cs.ANYONE_TO_USER: + continue + self._user_module.remove_folder_key(revoked_uid, "ADDRESSBOOKS", key, owner_key="SUBS") + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("put_addressbook_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + def post_addressbook_share(self, key: str, body: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Grant full permissions to one or several users. + + :param key: Address book key. + :param body: List of users (UIDs) to grant full permissions to. + :return: API envelope with updated user permissions. + """ + try: + target_uids: list[str] = [self._resolve_to_user(entry) for entry in body] + entries: list[AclEntry] = self.module.grant_addressbook_share(self.user, key, target_uids) + self._grant_folder_subs_keys(target_uids, key) + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("post_addressbook_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + @staticmethod + def _resolve_to_user(entry: dict[str, Any]) -> str: + """A "anyone" user_class always collapses to the SOGo pseudo-user "".""" + if entry.get("user_class") == cs.USER_CLASS_ANY: + return cs.ANYONE_TO_USER + return entry["uid"] + + def _grant_folder_subs_keys(self, target_uids: Iterable[str], key: str) -> None: + """Add ``key`` to folders.ADDRESSBOOKS.SUBS for each target uid so it surfaces in their webmail. + + Cross-module orchestration (ModuleContact + ModuleUserProfile) is intentionally kept in + this interface layer, since a module must never call another module directly. + """ + for target_uid in target_uids: + if target_uid == cs.ANYONE_TO_USER: + continue # The "anyone" pseudo-user has no real folders to update, so skip it. + self._user_module.add_folder_key(target_uid, "ADDRESSBOOKS", key, owner_key="SUBS") + + def _serialize_share_entries(self, entries: list[AclEntry]) -> list[dict[str, Any]]: + """Resolve each ACL entry's to_user into the API's ContactShareUserSchema shape. + + A to_user not known by any user source is still returned (user_class ANON) so the caller + can see the raw grant instead of silently losing it. + """ + module_us: ModuleUserSource | None = None + result: list[dict[str, Any]] = [] + for entry in entries: + if entry.to_user == cs.ANYONE_TO_USER: + result.append({"c_email": "", "uid": "", "user_class": cs.USER_CLASS_ANY, "rights": entry.rights}) + continue + if module_us is None: + module_us = ModuleUserSource.init_from_domain_settings(self._user_domain_settings) + target: User = User(uid=entry.to_user) + module_us.get_contact_info_for_user(target) + result.append({ + "c_email": target.uid, #TODO provisoire pour l'UI, target.mail if not target.anonymous else "", #TODO : return empty string for unknown users? + "uid": entry.to_user, + "user_class": cs.USER_CLASS_ANON if target.anonymous else "", #TODO : quand on aura user sources? on mettra le user_class de la source, sinon on mettra ANON pour les inconnus? + "rights": entry.rights, + }) + return result + # # Contacts # @@ -144,6 +268,9 @@ def get_contacts( surfaced through the X-Pagination header (built by the pagination decorator) rather than the response body. ``search`` is a separate full-text query argument. + When scoped to one address book, the response also carries ``rights``: what the current user + may do on that book (see ModuleContact.get_addressbook_rights). + :param key: Address book key, or None to span all the user's books. :param collection_param: Parsed pagination and sort arguments from the request. :param search: Optional full-text query. @@ -161,7 +288,10 @@ def get_contacts( order=order, ) serialized: list[dict[str, Any]] = self._contacts_serializer.serialize(contacts) - return total, *create_api_base_response({"contacts": serialized}) + data: dict[str, Any] = {"contacts": serialized} + if key is not None: + data["rights"] = self.module.get_addressbook_rights(self.user, key) + return total, *create_api_base_response(data) except RequestException as ex: logger_api.error("get_contacts failed for user %s book %s: %s", self.user.uid, key, ex) return 0, *create_api_base_response(None, ex.error) diff --git a/app/module/contact/ModuleContact.py b/app/module/contact/ModuleContact.py index de4c4db1..480e88e2 100644 --- a/app/module/contact/ModuleContact.py +++ b/app/module/contact/ModuleContact.py @@ -8,6 +8,7 @@ ALLOWED_FILE_MIME_TYPES, DEFAULT_ADDRESSBOOK_NAME, FILE_MAX_SIZE_KB, IMPORT_MAX_BYTES, ) from app.module.contact.acl.ContactAclEngine import ContactAclEngine +from app.factory.share.shareContact import FULL_MODIFY_RIGHTS, ShareContact from app.module.contact.jobs.ContactJobKind import ContactJobKind from app.module.contact.jobs.JobRequestExportContact import JobRequestExportContact from app.module.contact.jobs.JobRequestImportContact import JobRequestImportContact @@ -28,6 +29,7 @@ from app.auth.User import User from app.config.settings.DomainSettings import UserSourceSettingsObj from app.config.settings.ProcessSetting import ProcessSetting + from app.factory.share.RepositoryAcl import AclEntry from app.manager.agent.ClientAgent import ClientAgent from app.manager.cache.ClientRedis import ClientRedis from app.manager.db.ClientSQL import ClientSQL @@ -52,8 +54,9 @@ def __init__( self._db.connect() self._cache: ClientRedis | None = cache self._agent: ClientAgent | None = agent - self._sources: ContactSources = ContactSources(self._db) - self._acl: ContactAclEngine = ContactAclEngine() + self._share: ShareContact = ShareContact(self._db) + self._sources: ContactSources = ContactSources(self._db, share=self._share) + self._acl: ContactAclEngine = ContactAclEngine(share=self._share) self._file: ClientStorage = import_and_instantiate_manager( module_path="app.manager.storage", module_and_class_name=f"ClientStorage{process_settings.SOGO_P_STORAGE_TYPE.capitalize()}", @@ -71,7 +74,7 @@ def create_personal_addressbook(self, user_uid: str, name: str = DEFAULT_ADDRESS a new one. Called at first login alongside the personal calendar provisioning. """ for source in self._sources.get_all(user_uid): - if source.addressbook.is_default: + if source.addressbook.is_default and source.addressbook.user_uid == user_uid: return source.addressbook book: CardAddressBook = CardAddressBook( user_uid=user_uid, name=name, is_default=True, source_type=CardSourceType.LOCAL, @@ -86,7 +89,7 @@ def create_personal_addressbook(self, user_uid: str, name: str = DEFAULT_ADDRESS def get_all_addressbooks( self, user: User, user_sources: dict[str, UserSourceSettingsObj] | None = None, ) -> list[CardAddressBook]: - """Return all address books owned by the user (local DB books; directory when user_sources set).""" + """Return all address books owned by or shared with the user (local DB books; directory when user_sources set).""" return [source.addressbook for source in self._sources.get_all(user.uid, user_sources)] def get_addressbook( @@ -118,6 +121,21 @@ def _get_writable_addressbook( self._require_modify(source, user) return source + def get_addressbook_rights( + self, user: User, key: str, user_sources: dict[str, UserSourceSettingsObj] | None = None, + ) -> dict[str, bool]: + """Return the acting user's own rights on an address book, in the ContactShareRightsSchema shape. + + The owner has every right. Any other user gets the rights of their sogo6_acl entry (their own, + or the "anyone" one), each flag defaulting to False; no entry means no right at all. + """ + book: CardAddressBook = self.get_addressbook(user, key, user_sources).addressbook + if book.user_uid == user.uid: + return dict(FULL_MODIFY_RIGHTS) + entry: AclEntry | None = self._share.get_user_or_anyone(user.uid, book.user_uid, key) + stored: dict = entry.rights if entry is not None else {} + return {name: bool(stored.get(name, False)) for name in FULL_MODIFY_RIGHTS} + def create_addressbook( self, user: User, book: CardAddressBook, user_sources: dict[str, UserSourceSettingsObj] | None = None, ) -> CardAddressBook: @@ -141,10 +159,93 @@ def update_addressbook( def delete_addressbook( self, user: User, key: str, hard_delete: bool = False, user_sources: dict[str, UserSourceSettingsObj] | None = None, - ) -> None: - """Delete an address book; its contacts are tombstoned and detached (soft) or removed (hard).""" + ) -> list[str]: + """Delete an address book; its contacts are tombstoned and detached (soft) or removed (hard). + + Also cleans up any sogo6_acl rows granting other users access to this address book. + + :return: the list of uids that had a share on this address book (so the interface layer can + clean up their folders.ADDRESSBOOKS.SUBS entry too). + """ source: ContactSource = self._get_writable_addressbook(user, key, user_sources) + shared_uids: list[str] = [entry.to_user for entry in self._share.get_permissions(key)] source.delete_addressbook(hard_delete=hard_delete) + self._share.remove_all_permissions_for_key(key) + return shared_uids + + # + # Address book sharing + # + def _require_owned_addressbook(self, user: User, key: str) -> ContactSource: + """Return the address book source, raising ACCESS_DENIED if user is not its owner. + + Sharing management (list / grant / patch / put) is an owner-only operation: get_addressbook + now also resolves address books merely shared with user, so an explicit ownership check is + required here to prevent a sharee from managing the resource's ACL. + """ + source: ContactSource = self.get_addressbook(user, key) + if source.addressbook.user_uid != user.uid: + raise RequestException(error=err.ERROR_CONTACT_ACCESS_DENIED) + return source + + def get_addressbook_share(self, user: User, key: str) -> list[AclEntry]: + """Return all ACL entries (one per user) granted on the address book identified by key. + + The caller must be the owner of the address book. + """ + self._require_owned_addressbook(user, key) + return self._share.get_permissions(key) + + def grant_addressbook_share(self, user: User, key: str, target_uids: list[str]) -> list[AclEntry]: + """Grant full permissions on the address book to one or several users. + + :param user: the acting user, must own the address book. + :param key: opaque key of the address book to share. + :param target_uids: uids to grant full permissions to. + :raises RequestException: ERROR_CONTACT_ADDRESSBOOK_NOT_FOUND if the address book does not + exist; ERROR_CONTACT_ACCESS_DENIED if user does not own it; + ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself. + """ + book: CardAddressBook = self._require_owned_addressbook(user, key).addressbook + for target_uid in target_uids: + self._share.add_permissions(target_uid, key, book.user_uid, dict(FULL_MODIFY_RIGHTS)) + return self._share.get_permissions(key) + + def patch_addressbook_share(self, user: User, key: str, users: list[dict]) -> list[AclEntry]: + """Grant or update rights for one or several users, leaving other existing shares untouched. + + :param user: the acting user, must own the address book. + :param key: opaque key of the address book to share. + :param users: list of ``{"uid": ..., "rights": {...}}`` entries to upsert. + :raises RequestException: ERROR_CONTACT_ADDRESSBOOK_NOT_FOUND if the address book does not + exist; ERROR_CONTACT_ACCESS_DENIED if user does not own it; + ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself. + """ + book: CardAddressBook = self._require_owned_addressbook(user, key).addressbook + for entry in users: + self._share.add_permissions(entry["uid"], key, book.user_uid, entry["rights"]) + return self._share.get_permissions(key) + + def put_addressbook_share(self, user: User, key: str, users: list[dict]) -> list[AclEntry]: + """Replace all existing shares on the address book with exactly the given users' rights. + + Any user currently shared with but absent from ``users`` is revoked. + + :param user: the acting user, must own the address book. + :param key: opaque key of the address book to share. + :param users: list of ``{"uid": ..., "rights": {...}}`` entries; becomes the full set of shares. + :raises RequestException: ERROR_CONTACT_ADDRESSBOOK_NOT_FOUND if the address book does not + exist; ERROR_CONTACT_ACCESS_DENIED if user does not own it; + ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself. + """ + book: CardAddressBook = self._require_owned_addressbook(user, key).addressbook + new_uids: set[str] = {entry["uid"] for entry in users} + for existing in self._share.get_permissions(key): + if existing.to_user not in new_uids: + self._share.remove_permissions(existing.to_user, key) + for entry in users: + self._share.add_permissions(entry["uid"], key, book.user_uid, entry["rights"]) + return self._share.get_permissions(key) # # Contacts diff --git a/app/module/contact/acl/ContactAclEngine.py b/app/module/contact/acl/ContactAclEngine.py index 0ff4b82d..db5dc67b 100644 --- a/app/module/contact/acl/ContactAclEngine.py +++ b/app/module/contact/acl/ContactAclEngine.py @@ -8,27 +8,38 @@ if TYPE_CHECKING: from app.auth.User import User + from app.factory.share.RepositoryAcl import AclEntry + from app.factory.share.shareContact import ShareContact from app.module.contact.model.CardAddressBook import CardAddressBook class ContactAclEngine: """Resolves and enforces address book permissions. - Centralizes contact ACL logic: access-level resolution and action checks. Currently stubbed - - the owner gets MODIFY on their own books, a non-owner is denied. Will be connected to the - centralized ACL module (internal sharing) when it is implemented. + Centralizes contact ACL logic: access-level resolution and action checks. Owner gets full + access; a non-owner's level is resolved from the sogo6_acl-backed ``ShareContact`` when one + is supplied, denied otherwise (e.g. legacy/unit-test callers that construct the engine + without a share resolver). """ + def __init__(self, share: ShareContact | None = None) -> None: + self._share: ShareContact | None = share + def get_share_level(self, addressbook: CardAddressBook, user: User) -> ContactShareLevel | None: """Resolve the acting user's access level on an address book, or None when denied. - The owner gets MODIFY on their own books; a non-owner is denied (stub) until the ACL module - provides shared levels. + The owner gets MODIFY on their own books. A non-owner's level comes from the sogo6_acl + entry granted on this book (see ShareContact.get_user_or_anyone), or denied when none + exists or no share resolver was supplied. """ if addressbook.user_uid == user.uid: return ContactShareLevel.MODIFY - # TODO: look up shared permissions from the centralized ACL module - return None + if self._share is None or addressbook.key is None: + return None + entry: AclEntry | None = self._share.get_user_or_anyone(user.uid, addressbook.user_uid, addressbook.key) + if entry is None: + return None + return self._share.to_share_level(entry.rights) def check_permission(self, level: ContactShareLevel | None, required: ContactShareLevel) -> None: """Raise ERROR_CONTACT_ACCESS_DENIED when the resolved level is below the required one. diff --git a/app/module/contact/repository/RepositoryAddressBook.py b/app/module/contact/repository/RepositoryAddressBook.py index 137abc7a..9c175ec8 100644 --- a/app/module/contact/repository/RepositoryAddressBook.py +++ b/app/module/contact/repository/RepositoryAddressBook.py @@ -102,6 +102,23 @@ def find_by_key(self, user_uid: str, key: str) -> CardAddressBook | None: return None return self._row_to_addressbook(rows[0]) + def find_by_key_only(self, key: str) -> CardAddressBook | None: + """Return the address book matching key, regardless of owner. + + Unlike find_by_key, not scoped to a user_uid: used by the sharing feature, where the + caller (a prospective sharee, or the share management module) does not necessarily own + the address book. The key itself (an opaque generated uuid) is the lookup capability. + """ + rows = list(self._db.select_from_table( + table_name=tbl.TABLE_ADDRESSBOOK.name, + column_tuple=_ALL_COLS, + condition=EqualCondition(tbl.COL_AB_KEY.name, key), + limit=1, + )) + if not rows: + return None + return self._row_to_addressbook(rows[0]) + def get_default_for_user(self, user_uid: str) -> CardAddressBook | None: """Return the default address book for user_uid, or None if not found.""" condition = AndCondition( diff --git a/app/module/contact/serializer/CardAddressBookSerializerDict.py b/app/module/contact/serializer/CardAddressBookSerializerDict.py index 05aaa834..689d34b6 100644 --- a/app/module/contact/serializer/CardAddressBookSerializerDict.py +++ b/app/module/contact/serializer/CardAddressBookSerializerDict.py @@ -19,4 +19,5 @@ def serialize(self, data: CardAddressBook) -> dict[str, Any]: "is_default": data.is_default, "source_type": data.source_type.value, "ctag": data.ctag, + "owner": data.user_uid, } diff --git a/app/module/contact/source/ContactSources.py b/app/module/contact/source/ContactSources.py index c7c969e8..d2016442 100644 --- a/app/module/contact/source/ContactSources.py +++ b/app/module/contact/source/ContactSources.py @@ -8,13 +8,16 @@ from app.module.contact.repository.RepositoryContact import RepositoryContact from app.module.contact.repository.RepositoryContactList import RepositoryContactList from app.module.contact.source.ContactSourceDb import SORTABLE_COLUMNS, ContactSourceDb +from app.utils import constants as cs from app.utils import errors as err from app.utils.db.Condition import Order from app.utils.exceptions import RequestException from app.utils.logger.logger import logger_contact +from app.utils.strings import get_domain_from_mail if TYPE_CHECKING: from app.config.settings.DomainSettings import UserSourceSettingsObj + from app.factory.share.shareContact import ShareContact from app.manager.db.ClientSQL import ClientSQL from app.manager.storage.ClientStorage import ClientStorage from app.module.contact.model.CardAddressBook import CardAddressBook @@ -35,9 +38,10 @@ class ContactSources: for the annuaire (SQL or LDAP), one ContactSourceDirectory per source. """ - def __init__(self, db: ClientSQL) -> None: + def __init__(self, db: ClientSQL, share: ShareContact | None = None) -> None: self._db = db self._repo_addressbook = RepositoryAddressBook(db) + self._share: ShareContact | None = share def purge_orphans(self, file_store: ClientStorage) -> int: """Physically remove soft-deleted rows, dangling list memberships and orphan media; return total reclaimed. @@ -68,9 +72,39 @@ def get(self, addressbook: CardAddressBook, user_sources: dict[str, UserSourceSe logger_contact.error("Unknown source_type=%s for address book key=%s", addressbook.source_type, addressbook.key) raise RequestException(error=err.ERROR_CONTACT_ADDRESSBOOK_NOT_SUPPORTED) + def _get_shared_addressbooks(self, user_uid: str) -> list[CardAddressBook]: + """Return every address book shared with user_uid, directly or via an "anyone" share. + + Directly: sogo6_acl entries where to_user=user_uid. Via "anyone": sogo6_acl entries where + to_user="", restricted to books whose owner shares user_uid's mail domain (see + ShareContact.get_user_or_anyone). Skips entries whose key no longer resolves to a book + (deleted resource, stale ACL row) and books owned by user_uid. + """ + if self._share is None: + return [] + shared: list[CardAddressBook] = [] + seen_keys: set[str] = set() + for entry in self._share.get_keys_shared_with(user_uid): + book: CardAddressBook | None = self._repo_addressbook.find_by_key_only(entry.key) + if book is not None and book.key not in seen_keys and book.user_uid != user_uid: + shared.append(book) + seen_keys.add(book.key) + user_domain: str | None = get_domain_from_mail(user_uid) + if user_domain: + for entry in self._share.get_keys_shared_with(cs.ANYONE_TO_USER): + if entry.key in seen_keys: + continue + book = self._repo_addressbook.find_by_key_only(entry.key) + if (book is not None and book.user_uid != user_uid + and get_domain_from_mail(book.user_uid) == user_domain): + shared.append(book) + seen_keys.add(entry.key) + return shared + def get_all(self, user_uid: str, user_sources: dict[str, UserSourceSettingsObj] | None = None) -> list[ContactSource]: - """Return a source for every address book owned by user_uid (local books; directory later).""" - return [self.get(book, user_sources) for book in self._repo_addressbook.find_all(user_uid)] + """Return a source for every address book owned by, or shared with, user_uid (directory later).""" + owned: list[CardAddressBook] = self._repo_addressbook.find_all(user_uid) + return [self.get(book, user_sources) for book in owned + self._get_shared_addressbooks(user_uid)] def get_default(self, user_uid: str) -> ContactSource | None: """Return the default address book source for user_uid, or None if the user has none.""" @@ -80,12 +114,21 @@ def get_default(self, user_uid: str) -> ContactSource | None: def get_by_key( self, user_uid: str, key: str, user_sources: dict[str, UserSourceSettingsObj] | None = None, ) -> ContactSource | None: - """Return the source for a specific address book, or None if not found.""" + """Return the source for a specific address book, or None if not found. + + Resolves address books owned by user_uid, shared with user_uid directly (sogo6_acl), + and shared with "anyone" when user_uid shares the owner's mail domain (see + ShareContact.get_user_or_anyone). + """ # TODO directory: route on the key. Directory books carry a reserved "dir:" # prefix (a raw UUID never starts with it), so the branch is unambiguous: strip the prefix, # look the source_uid up in user_sources, build a synthetic directory book. A plain UUID # falls through to the DB lookup below. Blocked on the user source query primitive. book = self._repo_addressbook.find_by_key(user_uid, key) + if book is None and self._share is not None: + candidate: CardAddressBook | None = self._repo_addressbook.find_by_key_only(key) + if candidate is not None and self._share.get_user_or_anyone(user_uid, candidate.user_uid, key) is not None: + book = candidate return self.get(book, user_sources) if book is not None else None def get_contacts( # pylint: disable=too-many-locals diff --git a/app/utils/errors.py b/app/utils/errors.py index 2dcc50e7..96139159 100644 --- a/app/utils/errors.py +++ b/app/utils/errors.py @@ -252,6 +252,7 @@ def __init__(self, c:str, m:str, h:int = HTTPStatus.INTERNAL_SERVER_ERROR): ERROR_CONTACT_IMPORT_TOO_LARGE = E("S000717", "Import Payload Exceeds Maximum Allowed Size", HTTPStatus.REQUEST_ENTITY_TOO_LARGE) ERROR_CONTACT_IMPORT_PARSE_FAILED = E("S000718", "Failed To Parse The Import Document", HTTPStatus.UNPROCESSABLE_ENTITY) ERROR_CONTACT_DISPLAY_NAME_REQUIRED = E("S000719", "Contact Display Name Is Required", HTTPStatus.UNPROCESSABLE_ENTITY) +ERROR_CONTACT_SHARING_DISABLED = E("S000720", "Address Book Sharing Is Disabled For This Domain", HTTPStatus.FORBIDDEN) #AGENT / TASK ERROR_JOB_NOT_FOUND = E("S000800", "Job Not Found", HTTPStatus.NOT_FOUND) diff --git a/tests/test_contact/test_AddressBookSerializerDict.py b/tests/test_contact/test_AddressBookSerializerDict.py index c8ba9660..644596a7 100644 --- a/tests/test_contact/test_AddressBookSerializerDict.py +++ b/tests/test_contact/test_AddressBookSerializerDict.py @@ -21,6 +21,7 @@ def test_serialize_addressbook(): "is_default": True, "source_type": "local", "ctag": 7, + "owner": "alice", } diff --git a/tests/test_contact/test_ContactSources.py b/tests/test_contact/test_ContactSources.py index 4726ee9d..8b16650c 100644 --- a/tests/test_contact/test_ContactSources.py +++ b/tests/test_contact/test_ContactSources.py @@ -17,6 +17,7 @@ def _build(): sources = object.__new__(ContactSources) sources._db = MagicMock() sources._repo_addressbook = MagicMock() + sources._share = None return sources From 665c1acb69f30be019603c31dc29b260a4ddbb29 Mon Sep 17 00:00:00 2001 From: tkeriven Date: Thu, 27 Aug 2026 16:03:20 +0200 Subject: [PATCH 5/8] OP#2822 : add folders share API --- .devcontainer/conf/dovecot/sharing.db | 2 + app/api/v1/mail/ApiMailFolder.py | 100 +++++- app/api/v1/mail/schemas/folder.py | 290 ++++++++++++------ app/factory/share/shareMailFolder.py | 65 ++++ app/interface/mail/InterfaceApiMailFolder.py | 234 ++++++++------ app/manager/mail/ClientImap.py | 67 ++++ app/manager/mail/ClientMailServer.py | 15 + app/module/mail/ModuleMail.py | 182 ++++++----- app/utils/errors.py | 1 + playground/test_module_outgoing.py | 2 +- .../test_mail/test_InterfaceApiMailFolder.py | 174 +++++++---- .../test_manager/test_mail/test_clientImap.py | 39 +++ .../test_module/test_mail/test_moduleMail.py | 206 +++++++------ 13 files changed, 964 insertions(+), 413 deletions(-) create mode 100644 .devcontainer/conf/dovecot/sharing.db create mode 100644 app/factory/share/shareMailFolder.py diff --git a/.devcontainer/conf/dovecot/sharing.db b/.devcontainer/conf/dovecot/sharing.db new file mode 100644 index 00000000..ff522139 --- /dev/null +++ b/.devcontainer/conf/dovecot/sharing.db @@ -0,0 +1,2 @@ +shared/shared-boxes/user/sogo-tests2@example.org/sogo-tests1@example.org +1 diff --git a/app/api/v1/mail/ApiMailFolder.py b/app/api/v1/mail/ApiMailFolder.py index dcba66d0..5cbc716d 100644 --- a/app/api/v1/mail/ApiMailFolder.py +++ b/app/api/v1/mail/ApiMailFolder.py @@ -13,7 +13,9 @@ FolderCreateSchema, FolderUpdateSchema, FolderPurgeSchema, - FolderShareSchema, + FolderSharePatchSchema, + FolderSharePutSchema, + FolderSharePostSchema, FolderListResponseSchema, FolderCreateResponseSchema, FolderDetailsResponseSchema, @@ -211,14 +213,46 @@ def post(self, account_id: str, folder_name: str) -> ResponseReturnValue: @blp.route("//share") class ApiMailFolderIdShare(MethodView): - """API to share a specific mail folder. + """API to manage sharing of a specific mail folder and its users' permissions. + + Rights can be expressed two ways in the request body, and at least one of them must be + present for each user entry (an empty value is allowed and means "grant no rights"): + + - ``permissions``: a simplified list of IMAP ACL codes to grant, e.g. ``["l", "r"]``. + Any code not listed is considered not granted. + - ``rights``: an advanced object with one explicit 0/1 flag per right, e.g. + ``{"user_can_view_folder": 1, "user_can_read_mails": 1}``. + + Correspondence between simplified codes and advanced rights (see + ``FOLDER_PERMISSION_CODE_TO_RIGHT`` in ``app/api/v1/mail/schemas/folder.py``): + + | Code IMAP | Droit avancé | + |---|---| + | l | user_can_view_folder (Voir le dossier) | + | r | user_can_read_mails (Lire les mails) | + | s | user_can_mark_mails_read (Marquer comme lu/non lu) | + | w | user_can_write_mails (Modifier les indicateurs des mails) | + | i | user_can_insert_mails (Insérer, copier des mails) | + | p | user_can_post_mails (Envoyer des mails) | + | k | user_can_create_subfolders (Créer des sous-dossiers) | + | x | user_can_remove_folder (Supprimer le dossier) | + | t | user_can_erase_mails (Effacer les mails) | + | e | user_can_expunge_folder (Purger le dossier) | + | a | user_is_administrator (Administrer les droits du dossier) | + + If both ``permissions`` and ``rights`` are provided for the same entry, they must agree on + every right they both cover. Otherwise the API answers ``400 S001103`` + (``ERROR_SHARE_PERMISSIONS_RIGHTS_MISMATCH``). + + Each entry also requires ``user_class`` (``"user"`` or ``"anyone"``); ``c_email`` and ``uid`` + are required when ``user_class`` is ``"user"``. """ @blp.response(200, FolderShareResponseSchema, example=FolderShareResponseSchema.example()) def get(self, account_id: str, folder_name: str) -> ResponseReturnValue: #TODO: pagination? """Get share information for the specified folder. - + Returns the list of users who have access to this folder and their permissions. - + :param account_id: The ID of the account :type account_id: str :param folder_name: The ID of the folder @@ -230,14 +264,58 @@ def get(self, account_id: str, folder_name: str) -> ResponseReturnValue: #TOD interface: InterfaceApiMailFolder = g.inter return interface.get_folder_share(account_id, folder_name) - @blp.arguments(FolderShareSchema(many=True), example=FolderShareSchema.example(), error_status_code=400) #type: ignore [arg-type] + @blp.arguments(FolderSharePatchSchema(many=True), example=FolderSharePatchSchema.example(), error_status_code=400) # type: ignore [arg-type] + @blp.response(200, FolderShareResponseSchema, example=FolderShareResponseSchema.example()) + def patch(self, share_data: list, account_id: str, folder_name: str) -> ResponseReturnValue: + """Partially update sharing rights for the specified folder. + + Only the users specified in the request body are modified. Other existing shares + remain unchanged. See the resource docstring for the ``permissions``/``rights`` format. + + :param share_data: List of users with their rights configuration + :type share_data: list + :param account_id: The ID of the account + :type account_id: str + :param folder_name: The ID of the folder + :type folder_name: str + :return: ApiBaseResponse with share result + :rtype: ResponseReturnValue + """ + logger_api.debug("Calling ApiMailFolderIdShare.patch for account_id: %s, folder_name: %s with data: %s", + account_id, folder_name, share_data) + interface: InterfaceApiMailFolder = g.inter + return interface.patch_folder_share(account_id, folder_name, share_data) + + @blp.arguments(FolderSharePutSchema(many=True), example=FolderSharePutSchema.example(), error_status_code=400) # type: ignore [arg-type] + @blp.response(200, FolderShareResponseSchema, example=FolderShareResponseSchema.example()) + def put(self, share_data: list, account_id: str, folder_name: str) -> ResponseReturnValue: + """Replace all sharing rights for the specified folder. + + All existing shares are replaced by the users specified in the request body. + See the resource docstring for the ``permissions``/``rights`` format. + + :param share_data: List of users with their rights configuration + :type share_data: list + :param account_id: The ID of the account + :type account_id: str + :param folder_name: The ID of the folder + :type folder_name: str + :return: ApiBaseResponse with share result + :rtype: ResponseReturnValue + """ + logger_api.debug("Calling ApiMailFolderIdShare.put for account_id: %s, folder_name: %s with data: %s", + account_id, folder_name, share_data) + interface: InterfaceApiMailFolder = g.inter + return interface.put_folder_share(account_id, folder_name, share_data) + + @blp.arguments(FolderSharePostSchema(many=True), example=FolderSharePostSchema.example(), error_status_code=400) # type: ignore [arg-type] @blp.response(200, FolderShareResponseSchema, example=FolderShareResponseSchema.example()) def post(self, share_data: list, account_id: str, folder_name: str) -> ResponseReturnValue: - """Action: Share the specified folder with another user. - - Sets ACL permissions on the folder for the specified users. - The request body should be a list of user objects with their rights. - + """Grant sharing rights on the specified folder to one or several users. + + Adds or updates ACL permissions on the folder for the specified users, in addition to + any existing share. See the resource docstring for the ``permissions``/``rights`` format. + :param share_data: List of users with their rights configuration :type share_data: list :param account_id: The ID of the account @@ -250,4 +328,4 @@ def post(self, share_data: list, account_id: str, folder_name: str) -> ResponseR logger_api.debug("Calling ApiMailFolderIdShare.post for account_id: %s, folder_name: %s with data: %s", account_id, folder_name, share_data) interface: InterfaceApiMailFolder = g.inter - return interface.share_folder(account_id, folder_name, share_data) + return interface.post_folder_share(account_id, folder_name, share_data) diff --git a/app/api/v1/mail/schemas/folder.py b/app/api/v1/mail/schemas/folder.py index 652e5bb8..5675873d 100644 --- a/app/api/v1/mail/schemas/folder.py +++ b/app/api/v1/mail/schemas/folder.py @@ -1,5 +1,10 @@ -from marshmallow import Schema, fields +from typing import Any + +from marshmallow import Schema, fields, validate, validates_schema, ValidationError from app.utils.api.ApiBaseResponse import ApiBaseResponse +# The correspondence table lives in app.factory.share.shareMailFolder - shared with ModuleMail's +# IMAP ACL calls (see ClientImap.set_acl_raw/get_acl_raw) - and re-imported here for schema use. +from app.factory.share.shareMailFolder import FOLDER_SHARE_PERMISSION_CODES, FOLDER_PERMISSION_CODE_TO_RIGHT class FolderCreateSchema(Schema): @@ -69,75 +74,186 @@ def example(cls) -> dict: } -class FolderShareRightsSchema(Schema): - """ - Schema for folder sharing rights. +class FolderShareRightsInputSchema(Schema): """ - userCanEraseMails = fields.Integer() - userCanExpungeFolder = fields.Integer() - userCanInsertMails = fields.Integer() - userIsAdministrator = fields.Integer() - userCanWriteMails = fields.Integer() - userCanMarkMailsRead = fields.Integer() - userCanViewFolder = fields.Integer() - userCanCreateSubfolders = fields.Integer() - userCanPostMails = fields.Integer() - userCanReadMails = fields.Integer() - userCanRemoveFolder = fields.Integer() - - -class FolderShareSchema(Schema): + Advanced permission rights (one flag per IMAP ACL code) for the folder sharing request body. + + Every field is a 0/1 flag and optional: only pass the rights you want to state explicitly. + See ``FOLDER_PERMISSION_CODE_TO_RIGHT`` for the IMAP code each field corresponds to. """ - Schema for a user entry in folder sharing. - Use with many=True to validate a list of users. + user_can_view_folder = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Voir le dossier (l)"}) + user_can_read_mails = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Lire les mails (r)"}) + user_can_mark_mails_read = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Marquer comme lu/non lu (s)"}) + user_can_write_mails = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Modifier les indicateurs des mails (w)"}) + user_can_insert_mails = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Insérer, copier des mails (i)"}) + user_can_post_mails = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Envoyer des mails (p)"}) + user_can_create_subfolders = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Créer des sous-dossiers (k)"}) + user_can_remove_folder = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Supprimer le dossier (x)"}) + user_can_erase_mails = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Effacer les mails (t)"}) + user_can_expunge_folder = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Purger le dossier (e)"}) + user_is_administrator = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Administrer les droits du dossier (a)"}) + + +class FolderShareEntrySchema(Schema): """ - is_group = fields.Integer() - c_email = fields.String(required=True) - cn = fields.String() - uid = fields.String(required=True) - user_class = fields.String() - rights = fields.Nested(FolderShareRightsSchema, ) + Base schema for a user (or "anyone") entry in a mail folder sharing request. - @classmethod - def example(cls) -> list: - """ - Example data for folder sharing. + Rights can be expressed two ways, and at least one of them must be present in the request + (an empty value is allowed and means "grant no rights", e.g. to revoke a user down to + no access while keeping the share entry): + + - ``permissions``: a simplified list of IMAP ACL codes to grant (``l r s w i p k x t e a``). + Any code not listed is considered not granted. + - ``rights``: an advanced object with one explicit 0/1 flag per right + (see :class:`FolderShareRightsInputSchema`). + + If both ``permissions`` and ``rights`` are provided, they must agree: each code in + ``permissions`` must match its corresponding ``rights`` flag (see + ``FOLDER_PERMISSION_CODE_TO_RIGHT`` for the code <-> flag mapping). Otherwise the API + answers ``400 S001103`` (see ``ERROR_SHARE_PERMISSIONS_RIGHTS_MISMATCH`` in + ``app/utils/errors.py``). - :return: Example folder share payload (a list). - :rtype: list + ``c_email`` and ``uid`` are required unless ``user_class`` is ``"anyone"``, in which case + they are ignored (the share applies to any authenticated user, not a specific one). + """ + c_email = fields.String(required=False, allow_none=True, metadata={"description": "User email address", "example": "a@a.fr"}) + uid = fields.String(required=False, allow_none=True, metadata={"description": "User UID", "example": "a@a.fr"}) + user_class = fields.String( + required=True, + validate=validate.OneOf(["user", "anyone"]), + metadata={"description": "'user' for a specific user (needs c_email/uid), 'anyone' for every authenticated user"} + ) + permissions = fields.List( + fields.String(validate=validate.OneOf(FOLDER_SHARE_PERMISSION_CODES)), + required=False, + metadata={"description": "Simplified list of IMAP ACL codes to grant: l, r, s, w, i, p, k, x, t, e, a"} + ) + rights = fields.Nested( + FolderShareRightsInputSchema, + required=False, + metadata={"description": "Advanced per-right 0/1 flags, cross-checked against 'permissions' if both are given"} + ) + do_subfolders = fields.Boolean( + load_default=False, dump_default=False, + metadata={"description": "Also apply these rights to all subfolders"} + ) + + @validates_schema + def validate_user_identity(self, data: dict[str, Any], **kwargs: Any) -> None: # pylint: disable=unused-argument + """Require c_email and uid unless user_class is 'anyone'.""" + if data.get("user_class") == "anyone": + return + errors: dict[str, list[str]] = {} + if not data.get("c_email"): + errors["c_email"] = ["Missing data for required field."] + if not data.get("uid"): + errors["uid"] = ["Missing data for required field."] + if errors: + raise ValidationError(errors) + + @validates_schema + def validate_permissions_or_rights(self, data: dict[str, Any], **kwargs: Any) -> None: # pylint: disable=unused-argument + """Require at least one of 'permissions' or 'rights' to be present. + + An empty value (``"permissions": []`` or ``"rights": {}``) is accepted: it explicitly + grants no rights, which is different from omitting both keys entirely. """ + if "permissions" not in data and "rights" not in data: + raise ValidationError( + "At least one of 'permissions' or 'rights' must be provided.", + field_name="_schema" + ) + + +class FolderSharePatchSchema(FolderShareEntrySchema): + """Request body item for PATCH /mailboxes/{account_id}/folders/{folder_name}/share. + + Partially updates the sharing rights of the specified users: only the users listed in the + request body are modified, other existing shares are left untouched. + The endpoint expects a JSON list of these objects (use with ``many=True``). + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" return [ { - "c_email": "tkeriven@snapshot.alinto.org", - "cn": "tkeriven", - "uid": "tkeriven@snapshot.alinto.org", + "uid": "a@a.fr", + "c_email": "a@a.fr", "user_class": "user", - "rights": { - "userCanInsertMails": 1, - "userCanMarkMailsRead": 1, - "userCanPostMails": 1, - "userCanReadMails": 1, - "userCanRemoveFolder": 1, - "userCanViewFolder": 1, - "userCanWriteMails": 1, - "userIsAdministrator": 1 - } + "permissions": ["l", "r"], + "do_subfolders": False + } + ] + + +class FolderSharePutSchema(FolderShareEntrySchema): + """Request body item for PUT /mailboxes/{account_id}/folders/{folder_name}/share. + + Replaces all sharing rights on the folder: existing shares are entirely replaced by the + users listed in the request body. + The endpoint expects a JSON list of these objects (use with ``many=True``). + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "user_class": "anyone", + "permissions": ["l", "r", "s", "w", "i", "p", "t", "e", "a"], + "do_subfolders": True }, { - "c_email": "jnadal@snapshot.alinto.org", - "cn": "jnadal", - "uid": "jnadal@snapshot.alinto.org", + "c_email": "a@a.fr", + "uid": "a@a.fr", + "user_class": "user", + "permissions": ["l", "r"], + "do_subfolders": False + } + ] + + +class FolderSharePostSchema(FolderShareEntrySchema): + """Request body item for POST /mailboxes/{account_id}/folders/{folder_name}/share. + + Grants (or creates) sharing rights for the specified users, in addition to any existing share. + The endpoint expects a JSON list of these objects (use with ``many=True``). + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "sogo-tests1@example.org", + "uid": "sogo-tests1@example.org", "user_class": "user", "rights": { - "userCanInsertMails": 1, - "userCanMarkMailsRead": 1, - "userCanPostMails": 1, - "userCanReadMails": 1, - "userCanRemoveFolder": 1, - "userCanViewFolder": 1, - "userCanWriteMails": 1, - "userIsAdministrator": 1 - } + "user_can_insert_mails": 1, + "user_can_mark_mails_read": 1, + "user_can_post_mails": 1, + "user_can_read_mails": 1, + "user_can_remove_folder": 1, + "user_can_view_folder": 1, + "user_can_write_mails": 1, + "user_is_administrator": 1 + }, + "permissions": ["l", "r", "s", "w", "i", "p", "x", "a"] + }, + { + "user_class": "anyone", + "permissions": ["l", "r"], + "do_subfolders": False } ] @@ -343,48 +459,44 @@ class FolderShareResponseSchema(ApiBaseResponse): """ Schema for GET/POST /mailboxes//folders//share response """ - data = fields.Dict(required=False, allow_none=True) + data = fields.List(fields.Dict(), required=False, allow_none=True) @classmethod def example(cls) -> dict: """Example response for folder share. - + :return: Example folder share response :rtype: dict """ return { "error_code": 0, "error_msg": "", - "data": { - "users": { - "tkeriven@snapshot.alinto.org": { - "user_class": "user", - "c_email": "tkeriven@snapshot.alinto.org", - "cn": "tkeriven", - "uid": "tkeriven@snapshot.alinto.org", - "rights": { - "userCanEraseMails": 1, - "userCanExpungeFolder": 1, - "userCanInsertMails": 1, - "userIsAdministrator": 1, - "userCanWriteMails": 1, - "userCanMarkMailsRead": 1, - "userCanViewFolder": 1, - "userCanCreateSubfolders": 1, - "userCanPostMails": 1, - "userCanReadMails": 1, - "userCanRemoveFolder": 1 - } - }, - "anyone": { - "user_class": "anyone", - "cn": "Tout utilisateur identifié", - "uid": "anyone", - "rights": { - "userCanViewFolder": 1, - "userCanReadMails": 1 - } + "data": [ + { + "user_class": "user", + "c_email": "sogo-tests1@example.org", + "uid": "sogo-tests1@example.org", + "rights": { + "userCanEraseMails": 1, + "userCanExpungeFolder": 1, + "userCanInsertMails": 1, + "userIsAdministrator": 1, + "userCanWriteMails": 1, + "userCanMarkMailsRead": 1, + "userCanViewFolder": 1, + "userCanCreateSubfolders": 1, + "userCanPostMails": 1, + "userCanReadMails": 1, + "userCanRemoveFolder": 1 + } + }, + { + "user_class": "anyone", + "uid": "anyone", + "rights": { + "userCanViewFolder": 1, + "userCanReadMails": 1 } } - } + ] } diff --git a/app/factory/share/shareMailFolder.py b/app/factory/share/shareMailFolder.py new file mode 100644 index 00000000..3a4f1838 --- /dev/null +++ b/app/factory/share/shareMailFolder.py @@ -0,0 +1,65 @@ +from __future__ import annotations + + +from app.factory.share.share import Share + +# Discriminant stored in sogo6_acl.type for mail folder shares. +FOLDER_RESOURCE_TYPE: str = "folder" + +# IMAP ACL codes exposed through the simplified `permissions` list (see FolderShareEntrySchema). +# Each code maps 1:1 to a boolean flag of the advanced `rights` object. +FOLDER_SHARE_PERMISSION_CODES: list[str] = ["l", "r", "s", "w", "i", "p", "k", "x", "t", "e", "a"] + +# code IMAP -> SOGo folder right name. The single correspondence table between the API's +# simplified `permissions` codes, the advanced `rights` flags, and the raw IMAP ACL characters +# sent to the mail server (see ClientImap.set_acl_raw/get_acl_raw). Lives here (not in the API +# schemas) so both the API layer and ModuleMail can share it without violating layering. +FOLDER_PERMISSION_CODE_TO_RIGHT: dict[str, str] = { + "l": "user_can_view_folder", # Voir le dossier + "r": "user_can_read_mails", # Lire les mails + "s": "user_can_mark_mails_read", # Marquer comme lu/non lu + "w": "user_can_write_mails", # Modifier les indicateurs des mails + "i": "user_can_insert_mails", # Insérer, copier des mails + "p": "user_can_post_mails", # Envoyer des mails + "k": "user_can_create_subfolders", # Créer des sous-dossiers + "x": "user_can_remove_folder", # Supprimer le dossier + "t": "user_can_erase_mails", # Effacer les mails + "e": "user_can_expunge_folder", # Purger le dossier + "a": "user_is_administrator", # Administrer les droits du dossier +} + + +def rights_to_imap_permissions(rights: dict[str, int]) -> str: + """Convert a resolved folder rights dict into the ordered raw IMAP ACL rights string. + + :param rights: full folder rights dict (one 0/1 flag per FOLDER_PERMISSION_CODE_TO_RIGHT entry). + :return: IMAP ACL characters to grant, in FOLDER_PERMISSION_CODE_TO_RIGHT's canonical order. + """ + return "".join(code for code, right in FOLDER_PERMISSION_CODE_TO_RIGHT.items() if rights.get(right)) + + +def imap_permissions_to_rights(imap_rights: str) -> dict[str, int]: + """Convert a raw IMAP ACL rights string into the full folder rights dict (0/1 flags). + + :param imap_rights: raw IMAP ACL characters as returned by GETACL (e.g. "lrswipkxtea"). + :return: full folder rights dict, one 0/1 flag per FOLDER_PERMISSION_CODE_TO_RIGHT entry. + """ + granted = set(imap_rights) + return {right: (1 if code in granted else 0) for code, right in FOLDER_PERMISSION_CODE_TO_RIGHT.items()} + + +class ShareMailFolder(Share): + """Sharing for mail folders, backed by sogo6_acl (type='folder'). + + The rights blob stored per (folder key, to_user) matches the API's + FolderShareRightsInputSchema: one 0/1 flag per IMAP ACL right (see + ``FOLDER_PERMISSION_CODE_TO_RIGHT`` above). + + ``rights_needed`` passed to ``check_permissions`` is the name of the right to check + (e.g. "user_can_read_mails"). + """ + + resource_type: str = FOLDER_RESOURCE_TYPE + + def _rights_satisfy(self, rights: dict, rights_needed: str) -> bool: + return bool(rights.get(rights_needed, False)) diff --git a/app/interface/mail/InterfaceApiMailFolder.py b/app/interface/mail/InterfaceApiMailFolder.py index eb284fb0..35e96bb3 100644 --- a/app/interface/mail/InterfaceApiMailFolder.py +++ b/app/interface/mail/InterfaceApiMailFolder.py @@ -3,12 +3,15 @@ from http import HTTPStatus from app.auth.User import User -from app.module.mail.ModuleMail import ModuleMail +from app.factory.share.RepositoryAcl import AclEntry from app.module.auth.ModuleUserSource import ModuleUserSource +from app.module.mail.ModuleMail import ModuleMail +from app.factory.share.shareMailFolder import FOLDER_PERMISSION_CODE_TO_RIGHT from app.config.settings.DomainSettings import MailSettings, MailSettingsObj +from app.utils import constants as cs +from app.utils import errors as err from app.utils.exceptions import RequestException from app.utils.api.ApiBaseResponse import create_api_base_response -from app.utils import constants as cs from app.utils.logger.logger import logger_api if TYPE_CHECKING: @@ -28,7 +31,7 @@ def __init__(self, process_setting: ProcessSetting, user_domain_settings: dict, self.mail_settings = MailSettingsObj(user_domain_settings[MailSettings.subparent]) self.user = user - self.mail_module = ModuleMail(self.user, self.mail_settings) + self.mail_module = ModuleMail(self.user, self.mail_settings, process_setting=process_setting) def get_folder_list(self, account_id: str) -> tuple[dict[str, Any], int]: """Retrieve the list of mail folders for a given account and return an ApiBaseResponse. @@ -186,7 +189,7 @@ def export_folder_mails(self, account_id: str, folder_name: str) -> tuple[dict[s def get_folder_share(self, account_id: str, folder_path: str) -> tuple[dict[str, Any], int]: """Get share information for the specified folder. - + :param account_id: The ID of the account :type account_id: str :param folder_path: The ID of the folder @@ -195,112 +198,147 @@ def get_folder_share(self, account_id: str, folder_path: str) -> tuple[dict[str, :rtype: tuple[dict[str, Any], int] """ try: - share_info: dict[str, dict[str, Any]] = {} - - # Only Instantiate Module User Source if we need it - module_us: ModuleUserSource|None = None - - for identifier, rights in self.mail_module.get_folder_share(account_id, folder_path): - if identifier == self.user.login_mail_server: - continue - if identifier == "anyone": - #Special indentifier means it is acl for everyone than can auth on the mail server - share_info[identifier] = { - "user_class": cs.USER_CLASS_ANY, - "c_email": "", - "cn": "", - "uid": "", - "rights": rights - } - continue - - if module_us is None: - module_us = ModuleUserSource.init_from_domain_settings(self.user_domain_settings) - #See if the identifier is known by us - user = User(identifier) - user.source_id = self.user.source_id - module_us.get_contact_info_for_user(user) - if user.anonymous: - #The user was not found - share_info[identifier] = { - "user_class": cs.USER_CLASS_ANON, - "c_email": "", - "cn": "", - "uid": identifier, - "rights": rights - } - else: - #TODO handlre groups. They start with '@' - share_info[identifier] = { - "user_class": cs.USER_CLASS_USER, - "c_email": user.mail, - "cn": user.cn, - "uid": user.uid, - "rights": rights - } - return create_api_base_response(share_info) + entries: list[AclEntry] = self.mail_module.get_folder_share(account_id, folder_path) except RequestException as ex: logger_api.error("Request exception in get_folder_share: %s", str(ex)) return create_api_base_response(None, ex.error) + return create_api_base_response(self._serialize_share_entries(entries)) + + def patch_folder_share(self, account_id: str, folder_path: str, share_data: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Partially update sharing rights for the specified folder. + + Only the users specified in share_data are modified; other existing shares are + left unchanged. - def share_folder(self, account_id: str, folder_path: str, share_data: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: - """Share the specified folder with another user. - :param account_id: The ID of the account :type account_id: str :param folder_path: The ID of the folder :type folder_path: str :param share_data: List of users with their rights configuration - :type share_data: List[dict[str, Any]] + :type share_data: list[dict[str, Any]] :return: A tuple of (API response dict, status code) :rtype: tuple[dict[str, Any], int] """ try: - share_info: dict[str, dict[str, Any]] = {} - - # Only Instantiate Module User Source if we need it - module_us: ModuleUserSource|None = None - - for identifier, rights in self.mail_module.share_folder(account_id, folder_path, share_data): - #TODO find a clerver way to factor this loop with get_folder_share() - if identifier == self.user.login_mail_server: - continue - if identifier == "anyone": - #Special indentifier means it is acl for everyone than can auth on the mail server - share_info[identifier] = { - "user_class": cs.USER_CLASS_ANY, - "c_email": "", - "cn": "", - "uid": "", - "rights": rights - } - continue - - if module_us is None: - module_us = ModuleUserSource.init_from_domain_settings(self.user_domain_settings) - #See if the identifier is known by us - user = User(identifier) - module_us.get_contact_info_for_user(user) - if user.anonymous: - #The user was not found - share_info[identifier] = { - "user_class": cs.USER_CLASS_ANON, - "c_email": "", - "cn": "", - "uid": identifier, - "rights": rights - } - else: - #TODO handlre groups. They start with '@' - share_info[identifier] = { - "user_class": cs.USER_CLASS_USER, - "c_email": user.mail, - "cn": user.cn, - "uid": user.uid, - "rights": rights - } - - return create_api_base_response(share_info) + users = [{"uid": self._resolve_to_user(entry), "rights": self._resolve_rights(entry)} for entry in share_data] + entries: list[AclEntry] = self.mail_module.patch_folder_share(account_id, folder_path, users) except RequestException as ex: - logger_api.error("Request exception in share_folder: %s", str(ex)) + logger_api.error("Request exception in patch_folder_share: %s", str(ex)) return create_api_base_response(None, ex.error) + return create_api_base_response(self._serialize_share_entries(entries)) + + def put_folder_share(self, account_id: str, folder_path: str, share_data: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Replace all sharing rights for the specified folder. + + Existing shares are entirely replaced by the users specified in share_data. + + :param account_id: The ID of the account + :type account_id: str + :param folder_path: The ID of the folder + :type folder_path: str + :param share_data: List of users with their rights configuration + :type share_data: list[dict[str, Any]] + :return: A tuple of (API response dict, status code) + :rtype: tuple[dict[str, Any], int] + """ + try: + users = [{"uid": self._resolve_to_user(entry), "rights": self._resolve_rights(entry)} for entry in share_data] + entries: list[AclEntry] = self.mail_module.put_folder_share(account_id, folder_path, users) + except RequestException as ex: + logger_api.error("Request exception in put_folder_share: %s", str(ex)) + return create_api_base_response(None, ex.error) + return create_api_base_response(self._serialize_share_entries(entries)) + + def post_folder_share(self, account_id: str, folder_path: str, share_data: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Grant sharing rights on the specified folder to one or several users. + + :param account_id: The ID of the account + :type account_id: str + :param folder_path: The ID of the folder + :type folder_path: str + :param share_data: List of users with their rights configuration + :type share_data: list[dict[str, Any]] + :return: A tuple of (API response dict, status code) + :rtype: tuple[dict[str, Any], int] + """ + try: + users = [{"uid": self._resolve_to_user(entry), "rights": self._resolve_rights(entry)} for entry in share_data] + entries: list[AclEntry] = self.mail_module.post_folder_share(account_id, folder_path, users) + except RequestException as ex: + logger_api.error("Request exception in post_folder_share: %s", str(ex)) + return create_api_base_response(None, ex.error) + return create_api_base_response(self._serialize_share_entries(entries)) + + def _resolve_to_user(self, entry: dict[str, Any]) -> str: + """Resolve the ACL to_user for a share entry. + + A "anyone" user_class always collapses to the SOGo pseudo-user "" in + sogo6_acl.to_user, regardless of whatever uid the caller may have supplied. + """ + if entry.get("user_class") == cs.USER_CLASS_ANY: + return cs.ANYONE_TO_USER + return entry["uid"] + + @staticmethod + def _resolve_rights(entry: dict[str, Any]) -> dict[str, int]: + """Build the full rights dict (one 0/1 flag per IMAP ACL right) for a share entry. + + When ``permissions`` is provided, any right not listed is not granted (0) - it fully + determines the entry's rights. When only ``rights`` is provided, any right it omits is + likewise not granted (0). When both are provided, they must agree on every right + ``permissions`` covers (i.e. every right, since an omitted code means "not granted"). + + :raises RequestException: ERROR_SHARE_PERMISSIONS_RIGHTS_MISMATCH if permissions and + rights disagree on a right they both cover. + """ + permissions: list[str] | None = entry.get("permissions") + rights_in: dict[str, int] = entry.get("rights") or {} + + if permissions is not None: + derived = {right: (1 if code in permissions else 0) for code, right in FOLDER_PERMISSION_CODE_TO_RIGHT.items()} + for right_name, value in rights_in.items(): + if derived.get(right_name) != value: + raise RequestException(error=err.ERROR_SHARE_PERMISSIONS_RIGHTS_MISMATCH) + return derived + + resolved: dict[str, int] = dict.fromkeys(FOLDER_PERMISSION_CODE_TO_RIGHT.values(), 0) + resolved.update(rights_in) + return resolved + + @staticmethod + def _snake_to_camel(name: str) -> str: + """Convert a snake_case right name (e.g. "user_can_view_folder") to camelCase.""" + first, *rest = name.split("_") + return first + "".join(word.capitalize() for word in rest) + + def _serialize_share_entries(self, entries: list[AclEntry]) -> list[dict[str, Any]]: + """Resolve ACL entries into the API's FolderShareResponseSchema shape. + + A to_user not known by any user source is still returned (user_class ANON) so the + caller can see the raw grant instead of silently losing it. The "" pseudo + to_user is the "anyone" share and is never resolved through the user source. + """ + module_us: ModuleUserSource | None = None + users: list[dict[str, Any]] = [] + for entry in entries: + granted_rights = {self._snake_to_camel(right): 1 for right, value in entry.rights.items() if value} + if entry.to_user == cs.ANYONE_TO_USER: + users.append({ + "user_class": cs.USER_CLASS_ANY, + "cn": "Tout utilisateur identifié", + "uid": cs.USER_CLASS_ANY, + "rights": granted_rights, + }) + continue + if module_us is None: + module_us = ModuleUserSource.init_from_domain_settings(self.user_domain_settings) + target: User = User(uid=entry.to_user) + module_us.get_contact_info_for_user(target) + users.append({ + "user_class": cs.USER_CLASS_ANON if target.anonymous else cs.USER_CLASS_USER, + "c_email": target.uid, + "cn": target.cn, + "uid": entry.to_user, + "rights": granted_rights, + }) + return users diff --git a/app/manager/mail/ClientImap.py b/app/manager/mail/ClientImap.py index 185c0d65..ff58bd95 100644 --- a/app/manager/mail/ClientImap.py +++ b/app/manager/mail/ClientImap.py @@ -1119,6 +1119,73 @@ def delete_acl(self, folder_path: str, identifier: str) -> None: else: raise BugException("Not authenticated meaning self.connect() and self.login() was not called beforehands") + def get_acl_raw(self, folder_path: str) -> Iterator[tuple[str, str]]: + """Get the raw Access Control List (ACL) for a folder, with no SOGo rights conversion. + + Uses the IMAP GETACL command and yields the IMAP rights characters exactly as returned + by the server (e.g. "lrswipkxtea"), for callers that already work with their own + rights-code correspondence table instead of the legacy SOGo rights dictionary. + + :param folder_path: The name of the folder to get ACL for. + :type folder_path: str + :yield: tuples of (identifier, imap_rights) where imap_rights is the raw ACL string. + :rtype: Iterator[tuple[str, str]] + :raises RequestException: If not connected to the server or if getting ACL fails. + """ + logger_imap.debug("Getting raw ACL for folder '%s'", folder_path) + if self.connection is not None and self.authenticated: + folder_path = self._fix_folder_path(folder_path) + folder_path = quote(folder_path) + success, datas = self._exec_imap4_method(self.connection.getacl, folder_path) + if not success: + first_error = datas[0] if isinstance(datas[0], str) else datas[0].decode() + if first_error.startswith("Mailbox doesn't exist"): + raise RequestException(f"Folder '{folder_path}' does not exist", err.ERROR_FOLDER_NAME_NOT_FOUND) + raise RequestException(f"Failed to get ACL for {folder_path}: {first_error}", err.ERROR_IMAP_FAILED) + + # Parse the response: data[0] is typically bytes like b'INBOX identifier1 rights1 identifier2 rights2 ...' + parts = datas[0].decode().split() + + # Skip first part (folder name) and yield identifier/rights pairs + i = 1 + while i < len(parts) - 1: + yield (parts[i], parts[i + 1]) + i += 2 + else: + raise BugException("Not authenticated meaning self.connect() and self.login() was not called beforehands") + + def set_acl_raw(self, folder_path: str, identifier: str, imap_rights: str) -> None: + """Set ACL rights for a specific user/identifier on a folder, with no SOGo rights conversion. + + Uses the IMAP SETACL command directly with the given IMAP rights characters (e.g. + "lrswipkxtea"), for callers that already work with their own rights-code correspondence + table instead of the legacy SOGo rights dictionary. + + :param folder_path: The name of the folder. + :type folder_path: str + :param identifier: The user identifier (email, username, or special like 'anyone'). + :type identifier: str + :param imap_rights: Raw IMAP ACL rights characters to grant (empty string revokes all). + :type imap_rights: str + :raises RequestException: If not connected to the server or if setting ACL fails. + """ + logger_imap.debug("Setting raw ACL for folder '%s', identifier '%s', IMAP rights '%s'", folder_path, identifier, imap_rights) + if self.connection is not None and self.authenticated: + folder_path = self._fix_folder_path(folder_path) + folder_path = quote(folder_path) + + success, datas = self._exec_imap4_method(self.connection.setacl, folder_path, identifier, imap_rights) + if not success: + first_error = datas[0] if isinstance(datas[0], str) else datas[0].decode() + if first_error.startswith("Mailbox doesn't exist"): + raise RequestException(f"Folder '{folder_path}' does not exist", err.ERROR_FOLDER_NAME_NOT_FOUND) + raise RequestException(f"Failed to set ACL for {folder_path}: {first_error}", err.ERROR_IMAP_FAILED) + + logger_imap.info("Successfully set raw ACL for folder '%s', identifier '%s', IMAP rights '%s'", + folder_path, identifier, imap_rights) + else: + raise BugException("Not authenticated meaning self.connect() and self.login() was not called beforehands") + ####### #MAILS# ####### diff --git a/app/manager/mail/ClientMailServer.py b/app/manager/mail/ClientMailServer.py index 840bf2b2..eb1f5992 100644 --- a/app/manager/mail/ClientMailServer.py +++ b/app/manager/mail/ClientMailServer.py @@ -178,6 +178,21 @@ def get_acl(self, folder_path: str) -> Iterator[tuple[str, dict[str, int]]]: :raises RequestException: If not connected to the server or if getting ACL fails. """ + @abstractmethod + def get_acl_raw(self, folder_path: str) -> Iterator[tuple[str, str]]: + """Get the raw Access Control List (ACL) for a folder, with no SOGo rights conversion. + + Uses the IMAP GETACL command and yields the IMAP rights characters exactly as returned + by the server (e.g. "lrswipkxtea"), for callers that already work with their own + rights-code correspondence table instead of the legacy SOGo rights dictionary. + + :param folder_path: The name of the folder to get ACL for. + :type folder_path: str + :yield: tuples of (identifier, imap_rights) where imap_rights is the raw ACL string. + :rtype: Iterator[tuple[str, str]] + :raises RequestException: If not connected to the server or if getting ACL fails. + """ + @abstractmethod def set_acl(self, folder_path: str, identifier: str, rights: dict[str, Any]) -> None: """Set ACL rights for a specific user/identifier on a folder. diff --git a/app/module/mail/ModuleMail.py b/app/module/mail/ModuleMail.py index 49a19558..40fa12de 100644 --- a/app/module/mail/ModuleMail.py +++ b/app/module/mail/ModuleMail.py @@ -8,12 +8,17 @@ from email.message import EmailMessage from email.message import Message from email.utils import parseaddr, getaddresses, make_msgid, formatdate +from hashlib import sha256 from io import BytesIO from re import search as reg_search import zipfile from app.config.settings.UserSettings import UserMailViewSettings, UserMailViewSettingsObj, UserMailGeneralSettings +from app.factory.share.RepositoryAcl import AclEntry +from app.factory.share.shareMailFolder import ( + FOLDER_RESOURCE_TYPE, ShareMailFolder, imap_permissions_to_rights, rights_to_imap_permissions, +) from app.module.mail.model.TmpDraftManager import TmpDraftManager from app.manager.mail.ClientMailServer import ClientMailServer from app.utils import constants as cs @@ -49,6 +54,7 @@ def __init__(self, user: User, mail_settings: MailSettingsObj, process_setting: self.domain_mail_folder_name: dict = {} self._process_setting: ProcessSetting | None = process_setting self._db: ClientSQL | None = None + self._share: ShareMailFolder | None = None def _get_db(self) -> ClientSQL: """Return the DB client, lazily initialising it on first call. @@ -68,6 +74,23 @@ def _get_db(self) -> ClientSQL: self._db.connect() return self._db + def _get_share(self) -> ShareMailFolder: + """Return the mail folder ACL sharing helper, lazily initialising it on first call.""" + if self._share is None: + self._share = ShareMailFolder(self._get_db()) + return self._share + + @staticmethod + def _folder_acl_key(account_id: str, folder_path: str) -> str: + """Build a stable sogo6_acl key for a folder from (account_id, folder_path). + + Mail folders have no opaque key like calendars/addressbooks - they are addressed by + their literal IMAP path, which can exceed sogo6_acl.key's 64-char cap for deep folder + hierarchies. Hashed so the key stays deterministic and within bounds regardless of + path length. + """ + return sha256(f"{account_id}\x00{folder_path}".encode("utf-8")).hexdigest() + def _get_user_conf(self, account_id: str) -> dict: user_mail_conf: dict = {} if account_id == cs.DEFAULT_IDENTITY_KEY_VALUE: @@ -344,91 +367,110 @@ def purge_all_folders(self, account_id: str, purge_data: dict[str, Any]) -> dict ) return {"mails_deleted": total_deleted} - def get_folder_share(self, account_id: str, folder_path: str) -> Iterator[tuple[str, dict[str, int]]]: + @staticmethod + def _imap_identifier(to_user: str) -> str: + """Map a sogo6_acl to_user to the IMAP ACL identifier. + + The "" pseudo to_user (cs.ANYONE_TO_USER, a SOGo/DB-only convention) maps to + the real IMAP special identifier "anyone" (RFC 4314); any other to_user is used as-is. """ - Yield the acl for a folder. - (identifier, {right1: 1, right2: 0, ...}) + return cs.USER_CLASS_ANY if to_user == cs.ANYONE_TO_USER else to_user + + def get_folder_share(self, account_id: str, folder_path: str) -> list[AclEntry]: + """Return all ACL entries (one per user) currently granted on a folder, read live from IMAP. + + IMAP is the source of truth for actual mail access. - :param account_id: _description_ + :param account_id: The account identifier :type account_id: str - :param folder_path: _description_ + :param folder_path: The name of the folder :type folder_path: str - :yield: _description_ - :rtype: Iterator[tuple[str, dict[str, int]]] + :return: List of ACL entries for the folder + :rtype: list[AclEntry] """ client = self._open_client_for(account_id) - # Get ACL from client (already converted to SOGo rights format) - yield from client.get_acl(folder_path) + key = self._folder_acl_key(account_id, folder_path) + entries: list[AclEntry] = [] + for identifier, imap_rights in client.get_acl_raw(folder_path): + to_user = cs.ANYONE_TO_USER if identifier == cs.USER_CLASS_ANY else identifier + if to_user == self.user.uid: + continue + entries.append(AclEntry( + resource_type=FOLDER_RESOURCE_TYPE, key=key, owner=self.user.uid, to_user=to_user, + rights=imap_permissions_to_rights(imap_rights), + )) + return entries + def patch_folder_share(self, account_id: str, folder_path: str, users: list[dict]) -> list[AclEntry]: + """Grant or update rights for one or several users on a folder, leaving other shares untouched. + Writes the live IMAP ACL (source of truth for mail access) and mirrors it into + sogo6_acl (type='folder'). - def share_folder(self, account_id:str, folder_path: str, share_data: list[dict[str, Any]]) -> Iterator[tuple[str, dict[str, int]]]: - """Share the specified folder with another user. - - :param folder_name: The name of the folder - :type folder_name: str - :param share_data: list of users with their rights configuration - :type share_data: list[dict[str, Any]] - :return: Share result data - :rtype: dict[str, Any] - :raises RequestException: If validation or manager operations fail + :param account_id: The account identifier + :type account_id: str + :param folder_path: The name of the folder + :type folder_path: str + :param users: list of ``{"uid": ..., "rights": {...}}`` entries to upsert + :type users: list[dict] + :return: List of ACL entries for the folder after the update + :rtype: list[AclEntry] + :raises RequestException: ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself """ client = self._open_client_for(account_id) + key = self._folder_acl_key(account_id, folder_path) + share = self._get_share() + for entry in users: + client.set_acl_raw(folder_path, self._imap_identifier(entry["uid"]), rights_to_imap_permissions(entry["rights"])) + share.add_permissions(entry["uid"], key, self.user.uid, entry["rights"]) + return share.get_permissions(key) - # Step 1: Get current ACL to know which users currently have permissions - current_acl = client.get_acl(folder_path) - current_users = {identifier for identifier, _ in current_acl} - - # Step 2: Build list of users from the incoming share_data - new_users_dict: dict[str, dict[str, Any]] = {} # identifier -> rights_dict + def put_folder_share(self, account_id: str, folder_path: str, users: list[dict]) -> list[AclEntry]: + """Replace all existing shares on a folder with exactly the given users' rights. - for user_entry in share_data: - # Extract user identifier (uid or c_email) - #TODO in fact, we need the user.login_mail_server - identifier = user_entry["c_email"] - rights_dict = user_entry.get("rights", {}) + Any user currently shared with but absent from ``users`` is revoked. Writes the live + IMAP ACL (source of truth for mail access) and mirrors it into sogo6_acl (type='folder'). - # Store rights dict directly (client will handle conversion) - new_users_dict[identifier] = rights_dict - - logger_mail_server.info("New users dict from share_data: %s", new_users_dict) - - # Step 3: Determine which users need to be removed (present in current but not in new) - users_to_remove = current_users - set(new_users_dict.keys()) - logger_mail_server.info("Users to be removed: %s", users_to_remove) - - # Step 4: Remove ACL for users not in the new list (except owner) - for user_to_remove in users_to_remove: - # Skip owner to avoid locking them out - if user_to_remove == self.user.login_mail_server: - continue - try: - client.delete_acl(folder_path, user_to_remove) - logger_mail_server.info("Removed ACL for folder '%s', user '%s'", folder_path, user_to_remove) - except RequestException as e: - logger_mail_server.warning("Failed to remove ACL for user '%s': %s", user_to_remove, e) - - # Step 5: Set/update ACL for users in the new list - for identifier, rights_dict in new_users_dict.items(): - # Check if any rights are set (at least one truthy value) - has_rights = any(rights_dict.values()) if rights_dict else False - - if has_rights: - # Set ACL for this user (client handles conversion) - try: - client.set_acl(folder_path, identifier, rights_dict) - logger_mail_server.info("Set ACL for folder '%s', user '%s', rights %s", folder_path, identifier, rights_dict) - except RequestException as e: - logger_mail_server.error("Failed to set ACL for user '%s': %s", identifier, e) - else: - # If no rights specified, delete the ACL entry - try: - client.delete_acl(folder_path, identifier) - logger_mail_server.info("Deleted ACL for folder '%s', user '%s' (no rights specified)", folder_path, identifier) - except RequestException as e: - logger_mail_server.warning("Failed to delete ACL for user '%s': %s", identifier, e) + :param account_id: The account identifier + :type account_id: str + :param folder_path: The name of the folder + :type folder_path: str + :param users: list of ``{"uid": ..., "rights": {...}}`` entries; becomes the full set of shares + :type users: list[dict] + :return: List of ACL entries for the folder after the replacement + :rtype: list[AclEntry] + :raises RequestException: ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself + """ + client = self._open_client_for(account_id) + key = self._folder_acl_key(account_id, folder_path) + share = self._get_share() + new_uids = {entry["uid"] for entry in users} + for existing in share.get_permissions(key): + if existing.to_user not in new_uids: + client.delete_acl(folder_path, self._imap_identifier(existing.to_user)) + share.remove_permissions(existing.to_user, key) + for entry in users: + client.set_acl_raw(folder_path, self._imap_identifier(entry["uid"]), rights_to_imap_permissions(entry["rights"])) + share.add_permissions(entry["uid"], key, self.user.uid, entry["rights"]) + return share.get_permissions(key) + + def post_folder_share(self, account_id: str, folder_path: str, users: list[dict]) -> list[AclEntry]: + """Grant sharing rights on a folder to one or several users, in addition to any existing share. + + Structurally identical to patch_folder_share (upsert the given users, leave others + untouched); kept as its own method so the API's PATCH/POST endpoints map 1:1 to ModuleMail. - yield from client.get_acl(folder_path) + :param account_id: The account identifier + :type account_id: str + :param folder_path: The name of the folder + :type folder_path: str + :param users: list of ``{"uid": ..., "rights": {...}}`` entries to upsert + :type users: list[dict] + :return: List of ACL entries for the folder after the update + :rtype: list[AclEntry] + :raises RequestException: ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself + """ + return self.patch_folder_share(account_id, folder_path, users) ############## #MAILS SERVER# diff --git a/app/utils/errors.py b/app/utils/errors.py index 96139159..ba630723 100644 --- a/app/utils/errors.py +++ b/app/utils/errors.py @@ -276,6 +276,7 @@ def __init__(self, c:str, m:str, h:int = HTTPStatus.INTERNAL_SERVER_ERROR): ERROR_SHARE_NOT_FOUND = E("S001100", "Share Not Found", HTTPStatus.NOT_FOUND) ERROR_SHARE_TARGET_USER_NOT_FOUND = E("S001101", "Target User Not Found", HTTPStatus.NOT_FOUND) ERROR_SHARE_CANNOT_SHARE_WITH_SELF = E("S001102", "Cannot Share A Resource With Its Own Owner", HTTPStatus.BAD_REQUEST) +ERROR_SHARE_PERMISSIONS_RIGHTS_MISMATCH = E("S001103", "Simplified 'permissions' And Advanced 'rights' Fields Are Inconsistent", HTTPStatus.BAD_REQUEST) #the bugs ERROR_UNKOWN = E("S999999", "Undefined Error", HTTPStatus.INTERNAL_SERVER_ERROR) diff --git a/playground/test_module_outgoing.py b/playground/test_module_outgoing.py index 3691877d..ea7ab0e4 100644 --- a/playground/test_module_outgoing.py +++ b/playground/test_module_outgoing.py @@ -16,7 +16,7 @@ SERVER = "192.168.69.34" PORT = 10125 -USERNAME = "tkeriven@snapshot.alinto.org" +USERNAME = "sogo-tests1@example.org" PASSWORD = "Banane2!" FROM_ADDR = USERNAME diff --git a/tests/test_interface/test_mail/test_InterfaceApiMailFolder.py b/tests/test_interface/test_mail/test_InterfaceApiMailFolder.py index 7273720a..ea1a9aef 100644 --- a/tests/test_interface/test_mail/test_InterfaceApiMailFolder.py +++ b/tests/test_interface/test_mail/test_InterfaceApiMailFolder.py @@ -3,6 +3,7 @@ Ces tests utilisent un fake ModuleMail pour tester la logique de l'interface. """ from app.interface.mail.InterfaceApiMailFolder import InterfaceApiMailFolder +from app.factory.share.RepositoryAcl import AclEntry from app.utils.exceptions import RequestException from app.utils import errors as err @@ -66,10 +67,8 @@ def __init__(self, user_conf, mail_module=None): class FakeModuleMail: """Fake ModuleMail for testing InterfaceApiMailFolder. - + Method signatures match ModuleMail (most methods receive account_id as first argument). - get_folder_share and share_folder return an iterator of (identifier, rights) tuples, - matching ModuleMail's Iterator[tuple[str, dict[str, int]]] return type. """ def __init__(self, user_conf=None): self.user_conf = user_conf @@ -83,7 +82,9 @@ def __init__(self, user_conf=None): self.get_one_folder_args = None self.purge_folder_mails_args = None self.get_folder_share_args = None - self.share_folder_args = None + self.patch_folder_share_args = None + self.put_folder_share_args = None + self.post_folder_share_args = None self.export_folder_mails_args = None # Configurable results @@ -94,9 +95,11 @@ def __init__(self, user_conf=None): self.update_folder_result = {"name": "UpdatedFolder"} self.get_one_folder_result = {"name": "INBOX", "path": "INBOX"} self.purge_folder_mails_result = {"mails_deleted": 10} - # Returns list of (identifier, rights) tuples (iterable, as ModuleMail yields them) + # Returns list of AclEntry, matching ModuleMail's DB+IMAP-backed ACL methods self.get_folder_share_result = [] - self.share_folder_result = [] + self.patch_folder_share_result = [] + self.put_folder_share_result = [] + self.post_folder_share_result = [] def get_folder_list(self, account_id): """Simulate getting folder list.""" @@ -138,14 +141,24 @@ def purge_folder_mails(self, account_id, folder_name, purge_data): return self.purge_folder_mails_result def get_folder_share(self, account_id, folder_path): - """Simulate getting folder share information. Returns iterable of (identifier, rights) tuples.""" + """Simulate reading a folder's live ACL entries from IMAP.""" self.get_folder_share_args = folder_path - return iter(self.get_folder_share_result) + return self.get_folder_share_result + + def patch_folder_share(self, account_id, folder_path, users): + """Simulate granting/updating a folder's ACL entries (IMAP + sogo6_acl), leaving others untouched.""" + self.patch_folder_share_args = (folder_path, users) + return self.patch_folder_share_result - def share_folder(self, account_id, folder_path, share_data): - """Simulate sharing a folder. Returns iterable of (identifier, rights) tuples.""" - self.share_folder_args = (folder_path, share_data) - return iter(self.share_folder_result) + def put_folder_share(self, account_id, folder_path, users): + """Simulate replacing all of a folder's ACL entries (IMAP + sogo6_acl).""" + self.put_folder_share_args = (folder_path, users) + return self.put_folder_share_result + + def post_folder_share(self, account_id, folder_path, users): + """Simulate granting a folder's ACL entries (IMAP + sogo6_acl), leaving others untouched.""" + self.post_folder_share_args = (folder_path, users) + return self.post_folder_share_result def export_folder_mails(self, folder_name): """Simulate exporting mails from a folder.""" @@ -388,87 +401,142 @@ def test_export_folder_mails_module_error(monkeypatch): assert status_code == 400 -# ========== Tests for get_folder_share ========== +# ========== Tests for folder share (GET/PATCH/PUT/POST) ========== +# ModuleMail is responsible for both the live IMAP ACL and its sogo6_acl (type "folder") +# mirror; the interface only resolves the request body then forwards to ModuleMail. def test_get_folder_share_success(monkeypatch): - """Test getting folder share information for a valid account (empty share list).""" + """Test getting share info for a folder, including the 'anyone' pseudo entry.""" fake_module = FakeModuleMail() - fake_module.get_folder_share_result = [] # No shares: yields nothing + fake_module.get_folder_share_result = [ + AclEntry( + resource_type="folder", key="k", owner="owner@example.com", to_user="", + rights={"user_can_view_folder": 1, "user_can_read_mails": 1, "user_can_write_mails": 0}, + ), + ] interface = make_interface(monkeypatch, fake_module) result, status_code = interface.get_folder_share(account_id=0, folder_path="INBOX") assert status_code == 200 - assert result["data"] == {} assert fake_module.get_folder_share_args == "INBOX" + users = result["data"] + anyone = next(u for u in users if u["uid"] == "anyone") + assert anyone["user_class"] == "anyone" + assert anyone["rights"] == {"userCanViewFolder": 1, "userCanReadMails": 1} -def test_get_folder_share_with_users(monkeypatch): - """Test getting folder share with existing users returns an 'anyone' entry.""" +def test_get_folder_share_module_error(monkeypatch): + """Test error handling when reading the folder's live IMAP ACL fails.""" fake_module = FakeModuleMail() - fake_module.get_folder_share_result = [ - ("anyone", {"read": 1, "write": 0}), - ] + fake_module.get_folder_share = lambda *args, **kwargs: (_ for _ in ()).throw(RequestException(error=err.ERROR_SHARE_NOT_FOUND)) interface = make_interface(monkeypatch, fake_module) result, status_code = interface.get_folder_share(account_id=0, folder_path="INBOX") + assert status_code == 404 + assert result["error_code"] == "S001100" + + +def test_patch_folder_share_success(monkeypatch): + """Test that 'permissions' codes resolve to a full 11-flag rights dict, unlisted rights denied.""" + fake_module = FakeModuleMail() + interface = make_interface(monkeypatch, fake_module) + + share_data = [{"uid": "bob@example.com", "c_email": "bob@example.com", "user_class": "user", "permissions": ["l", "r"]}] + result, status_code = interface.patch_folder_share(account_id=0, folder_path="INBOX", share_data=share_data) + assert status_code == 200 - assert "anyone" in result["data"] - assert result["data"]["anyone"]["rights"] == {"read": 1, "write": 0} + folder_path, users = fake_module.patch_folder_share_args + assert folder_path == "INBOX" + assert users == [{ + "uid": "bob@example.com", + "rights": { + "user_can_view_folder": 1, + "user_can_read_mails": 1, + "user_can_mark_mails_read": 0, + "user_can_write_mails": 0, + "user_can_insert_mails": 0, + "user_can_post_mails": 0, + "user_can_create_subfolders": 0, + "user_can_remove_folder": 0, + "user_can_erase_mails": 0, + "user_can_expunge_folder": 0, + "user_is_administrator": 0, + }, + }] + + +def test_patch_folder_share_anyone_user_class(monkeypatch): + """Test that user_class 'anyone' resolves to the ANYONE_TO_USER pseudo-uid.""" + fake_module = FakeModuleMail() + interface = make_interface(monkeypatch, fake_module) + share_data = [{"user_class": "anyone", "permissions": ["l", "r"]}] + interface.patch_folder_share(account_id=0, folder_path="INBOX", share_data=share_data) -def test_get_folder_share_module_error(monkeypatch): - """Test error handling when getting folder share fails.""" + _, users = fake_module.patch_folder_share_args + assert users[0]["uid"] == "" + + +def test_patch_folder_share_permissions_rights_mismatch(monkeypatch): + """Test that conflicting 'permissions' and 'rights' fields return the mismatch error.""" fake_module = FakeModuleMail() - fake_module.get_folder_share = lambda *args: (_ for _ in ()).throw(RequestException("Cannot get share", err.ERROR_VALIDATION_ERROR)) interface = make_interface(monkeypatch, fake_module) - result, status_code = interface.get_folder_share(account_id=0, folder_path="INBOX") + share_data = [{ + "uid": "bob@example.com", "c_email": "bob@example.com", "user_class": "user", + "permissions": ["l"], "rights": {"user_can_view_folder": 0}, + }] + result, status_code = interface.patch_folder_share(account_id=0, folder_path="INBOX", share_data=share_data) - assert result["error_code"] == "S000300" assert status_code == 400 + assert result["error_code"] == "S001103" -# ========== Tests for share_folder ========== - -def test_share_folder_success(monkeypatch): - """Test sharing a folder for a valid account (empty result).""" +def test_patch_folder_share_module_error(monkeypatch): + """Test error handling when the module rejects the share (e.g. sharing with oneself).""" fake_module = FakeModuleMail() - fake_module.share_folder_result = [] # No shares returned after update + fake_module.patch_folder_share = lambda *args, **kwargs: (_ for _ in ()).throw(RequestException(error=err.ERROR_SHARE_CANNOT_SHARE_WITH_SELF)) interface = make_interface(monkeypatch, fake_module) - share_data = [{"email": "user2@example.com", "read": True, "write": True}] - result, status_code = interface.share_folder(account_id=0, folder_path="INBOX", share_data=share_data) + result, status_code = interface.patch_folder_share( + account_id=0, folder_path="INBOX", + share_data=[{"uid": "bob@example.com", "c_email": "bob@example.com", "user_class": "user", "permissions": ["l"]}], + ) - assert status_code == 200 - assert result["data"] == {} - assert fake_module.share_folder_args == ("INBOX", share_data) + assert status_code == 400 + assert result["error_code"] == "S001102" -def test_share_folder_with_anyone(monkeypatch): - """Test sharing a folder returns an 'anyone' ACL entry.""" +def test_put_folder_share_success(monkeypatch): + """Test that PUT resolves rights and forwards them to put_folder_share.""" fake_module = FakeModuleMail() - fake_module.share_folder_result = [ - ("anyone", {"read": 1, "write": 1}), - ] interface = make_interface(monkeypatch, fake_module) - share_data = [{"email": "anyone", "read": True, "write": True}] - result, status_code = interface.share_folder(account_id=0, folder_path="INBOX", share_data=share_data) + share_data = [{"uid": "bob@example.com", "c_email": "bob@example.com", "user_class": "user", "permissions": ["a"]}] + result, status_code = interface.put_folder_share(account_id=0, folder_path="INBOX", share_data=share_data) assert status_code == 200 - assert "anyone" in result["data"] - assert result["data"]["anyone"]["rights"] == {"read": 1, "write": 1} + folder_path, users = fake_module.put_folder_share_args + assert folder_path == "INBOX" + assert users[0]["uid"] == "bob@example.com" + assert users[0]["rights"]["user_is_administrator"] == 1 -def test_share_folder_module_error(monkeypatch): - """Test error handling when sharing folder fails.""" +def test_post_folder_share_success(monkeypatch): + """Test that POST resolves rights from the 'rights' field and forwards them to post_folder_share.""" fake_module = FakeModuleMail() - fake_module.share_folder = lambda *args: (_ for _ in ()).throw(RequestException("Cannot share", err.ERROR_VALIDATION_ERROR)) interface = make_interface(monkeypatch, fake_module) - result, status_code = interface.share_folder(account_id=0, folder_path="INBOX", share_data=[]) + share_data = [{ + "uid": "bob@example.com", "c_email": "bob@example.com", "user_class": "user", + "rights": {"user_can_view_folder": 1, "user_can_read_mails": 1}, + }] + result, status_code = interface.post_folder_share(account_id=0, folder_path="INBOX", share_data=share_data) - assert result["error_code"] == "S000300" - assert status_code == 400 + assert status_code == 200 + folder_path, users = fake_module.post_folder_share_args + assert folder_path == "INBOX" + assert users[0]["rights"]["user_can_view_folder"] == 1 + assert users[0]["rights"]["user_can_write_mails"] == 0 diff --git a/tests/test_manager/test_mail/test_clientImap.py b/tests/test_manager/test_mail/test_clientImap.py index 9be804ab..c6d3256d 100644 --- a/tests/test_manager/test_mail/test_clientImap.py +++ b/tests/test_manager/test_mail/test_clientImap.py @@ -818,6 +818,45 @@ def test_delete_acl_not_authenticated_raises(self): with pytest.raises(BugException): client.delete_acl("INBOX", "user") + def test_get_acl_raw_yields_raw_pairs(self): + fake_conn = FakeIMAPConnection() + fake_conn.getacl_response = ("OK", [b"INBOX user1 lrswipkxtea user2 lr"]) + client = authenticated_client(fake_conn) + + acl_list = list(client.get_acl_raw("INBOX")) + assert acl_list == [("user1", "lrswipkxtea"), ("user2", "lr")] + + def test_get_acl_raw_not_authenticated_raises(self): + client = make_client() + client.connection = None + with pytest.raises(BugException): + list(client.get_acl_raw("INBOX")) + + def test_get_acl_raw_failure_raises_request_exception(self): + fake_conn = FakeIMAPConnection() + fake_conn.getacl_response = ("NO", [b"Mailbox doesn't exist"]) + client = authenticated_client(fake_conn) + with pytest.raises(RequestException): + list(client.get_acl_raw("Ghost")) + + def test_set_acl_raw_success(self): + fake_conn = FakeIMAPConnection() + client = authenticated_client(fake_conn) + client.set_acl_raw("INBOX", "anyone", "lr") + + def test_set_acl_raw_not_authenticated_raises(self): + client = make_client() + client.connection = None + with pytest.raises(BugException): + client.set_acl_raw("INBOX", "user", "lr") + + def test_set_acl_raw_failure_raises_request_exception(self): + fake_conn = FakeIMAPConnection() + fake_conn.setacl_response = ("NO", [b"Mailbox doesn't exist"]) + client = authenticated_client(fake_conn) + with pytest.raises(RequestException): + client.set_acl_raw("Ghost", "user", "lr") + # =========================================================================== # Tests: uid_copy diff --git a/tests/test_module/test_mail/test_moduleMail.py b/tests/test_module/test_mail/test_moduleMail.py index 8eab4508..1d1f87c7 100644 --- a/tests/test_module/test_mail/test_moduleMail.py +++ b/tests/test_module/test_mail/test_moduleMail.py @@ -6,6 +6,7 @@ from io import BytesIO from unittest.mock import MagicMock from app.module.mail.ModuleMail import ModuleMail +from app.factory.share.RepositoryAcl import AclEntry from app.utils.exceptions import RequestException from app.utils.api.paginate_sort_filter import CollectionPaginateArgs @@ -36,6 +37,7 @@ def __init__(self): self.fetch_mail_result = None # set per test self.fetch_mail_raw_result = 'Subject: Test\r\n\r\nBody' self.get_acl_result = [('user1@example.com', {'userCanViewFolder': 1})] + self.get_acl_raw_result = [('user1@example.com', 'lr')] # Call tracking self.create_folder_calls = [] @@ -46,6 +48,7 @@ def __init__(self): self.delete_mails_by_uid_calls = [] self.set_acl_calls = [] self.delete_acl_calls = [] + self.set_acl_raw_calls = [] # ---- folder methods ---- @@ -140,6 +143,14 @@ def delete_acl(self, folder_name, identifier): """Delete ACL for a folder.""" self.delete_acl_calls.append((folder_name, identifier)) + def get_acl_raw(self, folder_path): + """Get the raw IMAP ACL for a folder (no SOGo rights conversion).""" + return self.get_acl_raw_result + + def set_acl_raw(self, folder_path, identifier, imap_rights): + """Set the raw IMAP ACL rights string for identifier on folder (no SOGo rights conversion).""" + self.set_acl_raw_calls.append((folder_path, identifier, imap_rights)) + def get_mail_uids_before_date(self, mailbox, before_date=None, exclude_deleted=True): """Get mail UIDs in a mailbox before a certain date.""" if before_date: @@ -290,6 +301,35 @@ def _make_module(monkeypatch, fake_client=None): return module, fake_client +class FakeShare: + """Fake ShareMailFolder for testing ModuleMail's sogo6_acl (type='folder') mirror.""" + + def __init__(self): + self.entries = {} # (key, to_user) -> AclEntry + self.add_permissions_calls = [] + self.remove_permissions_calls = [] + + def get_permissions(self, key): + return [entry for (stored_key, _), entry in self.entries.items() if stored_key == key] + + def add_permissions(self, for_user, on_key, owner, rights): + self.add_permissions_calls.append((for_user, on_key, owner, rights)) + self.entries[(on_key, for_user)] = AclEntry( + resource_type="folder", key=on_key, owner=owner, to_user=for_user, rights=rights, + ) + + def remove_permissions(self, for_user, on_key): + self.remove_permissions_calls.append((for_user, on_key)) + self.entries.pop((on_key, for_user), None) + + +def _make_share(monkeypatch, module): + """Patch module._get_share to return a fresh FakeShare, and return it for assertions.""" + fake_share = FakeShare() + monkeypatch.setattr(module, '_get_share', lambda: fake_share) + return fake_share + + # ========== Tests for initialization ========== def test_module_init_success(): @@ -564,45 +604,82 @@ def test_get_mail_raw_success(monkeypatch): # ========== Tests for get_folder_share ========== def test_get_folder_share_success(monkeypatch): - """Test getting folder share information (yields tuples).""" + """Test getting folder share information reads the live IMAP ACL (source of truth).""" module, fake_client = _make_module(monkeypatch) - fake_client.get_acl_result = [ - ('user1@example.com', {'userCanViewFolder': 1, 'userCanReadMails': 1}), - ('anyone', {'userCanViewFolder': 1}) + fake_client.get_acl_raw_result = [ + ('user1@example.com', 'lr'), + ('anyone', 'l'), ] - result = list(module.get_folder_share(ACCOUNT_ID, "INBOX")) - identifiers = [item[0] for item in result] - assert 'user1@example.com' in identifiers - assert 'anyone' in identifiers + result = module.get_folder_share(ACCOUNT_ID, "INBOX") + + to_users = {entry.to_user for entry in result} + assert 'user1@example.com' in to_users + assert '' in to_users # IMAP 'anyone' identifier maps to the sogo6_acl pseudo to_user + user1 = next(entry for entry in result if entry.to_user == 'user1@example.com') + assert user1.rights['user_can_view_folder'] == 1 + assert user1.rights['user_can_read_mails'] == 1 -# ========== Tests for share_folder ========== +# ========== Tests for patch_folder_share / put_folder_share / post_folder_share ========== -def test_share_folder_success(monkeypatch): - """Test sharing a folder with users.""" +def test_patch_folder_share_success(monkeypatch): + """Test patching folder share sets the live IMAP ACL and mirrors it into sogo6_acl.""" module, fake_client = _make_module(monkeypatch) - module.user.login_mail_server = 'owner@example.com' - fake_client.get_acl_result = [] + fake_share = _make_share(monkeypatch, module) - def get_acl_after_share(folder_path): - if fake_client.set_acl_calls: - return [('user1@example.com', {'userCanViewFolder': 1, 'userCanReadMails': 1})] - return [] + users = [{"uid": "user1@example.com", "rights": {"user_can_view_folder": 1, "user_can_read_mails": 1}}] + result = module.patch_folder_share(ACCOUNT_ID, "INBOX", users) - fake_client.get_acl = get_acl_after_share + assert len(fake_client.set_acl_raw_calls) == 1 + folder_path, identifier, imap_rights = fake_client.set_acl_raw_calls[0] + assert folder_path == "INBOX" + assert identifier == "user1@example.com" + assert set(imap_rights) == {"l", "r"} + assert len(fake_share.add_permissions_calls) == 1 + assert result[0].to_user == "user1@example.com" - share_data = [ - { - "c_email": "user1@example.com", - "rights": {"userCanViewFolder": 1, "userCanReadMails": 1} - } - ] - result = list(module.share_folder(ACCOUNT_ID, "INBOX", share_data)) - assert len(fake_client.set_acl_calls) >= 1 - # share_folder yields (identifier, rights) tuples - assert any(item[0] == 'user1@example.com' for item in result) +def test_patch_folder_share_anyone_maps_to_imap_identifier(monkeypatch): + """Test that the '' pseudo to_user maps to the IMAP special identifier 'anyone'.""" + module, fake_client = _make_module(monkeypatch) + _make_share(monkeypatch, module) + + users = [{"uid": "", "rights": {"user_can_view_folder": 1}}] + module.patch_folder_share(ACCOUNT_ID, "INBOX", users) + + assert fake_client.set_acl_raw_calls[0][1] == "anyone" + + +def test_put_folder_share_revokes_missing_users(monkeypatch): + """Test that PUT revokes users no longer in the list, on both IMAP and sogo6_acl.""" + module, fake_client = _make_module(monkeypatch) + fake_share = _make_share(monkeypatch, module) + key = module._folder_acl_key(ACCOUNT_ID, "INBOX") + fake_share.entries[(key, "user2@example.com")] = AclEntry( + resource_type="folder", key=key, owner=module.user.uid, to_user="user2@example.com", + rights={"user_can_view_folder": 1}, + ) + + users = [{"uid": "user1@example.com", "rights": {"user_can_view_folder": 1}}] + result = module.put_folder_share(ACCOUNT_ID, "INBOX", users) + + assert fake_client.delete_acl_calls == [("INBOX", "user2@example.com")] + assert fake_client.set_acl_raw_calls[0][1] == "user1@example.com" + to_users = {entry.to_user for entry in result} + assert to_users == {"user1@example.com"} + + +def test_post_folder_share_behaves_like_patch(monkeypatch): + """Test that POST upserts the given users without touching others (same as PATCH).""" + module, fake_client = _make_module(monkeypatch) + _make_share(monkeypatch, module) + + users = [{"uid": "user1@example.com", "rights": {"user_can_view_folder": 1}}] + result = module.post_folder_share(ACCOUNT_ID, "INBOX", users) + + assert len(fake_client.set_acl_raw_calls) == 1 + assert result[0].to_user == "user1@example.com" # ========== Tests for perform_mail_action ========== @@ -1213,75 +1290,22 @@ def fetch_all_without_content(mailbox, number_of_mails, offset=0, deleted=False) assert 'contents' not in result[0] -# ========== Additional Tests for share_folder with removal ========== +# ========== Additional tests for put_folder_share with multiple users ========== -def test_share_folder_with_user_removal(monkeypatch): - """Test sharing a folder and removing a previously shared user.""" +def test_put_folder_share_with_multiple_users(monkeypatch): + """Test replacing a folder's share with multiple users sets IMAP ACL for each.""" module, fake_client = _make_module(monkeypatch) - module.user.login_mail_server = 'owner@example.com' - - # Initial ACL with two users - def get_acl_mock(folder_path): - if fake_client.set_acl_calls: - return [ - ('user1@example.com', {'userCanViewFolder': 1, 'userCanReadMails': 1}), - ('user2@example.com', {'userCanViewFolder': 1}) - ] - return [ - ('user1@example.com', {'userCanViewFolder': 1, 'userCanReadMails': 1}), - ('user2@example.com', {'userCanViewFolder': 1}) - ] + _make_share(monkeypatch, module) - fake_client.get_acl = get_acl_mock - - # Only share with user1, removing user2 - share_data = [ - { - "c_email": "user1@example.com", - "rights": {"userCanViewFolder": 1, "userCanReadMails": 1} - } + users = [ + {"uid": "user1@example.com", "rights": {"user_can_view_folder": 1, "user_can_read_mails": 1}}, + {"uid": "user2@example.com", "rights": {"user_can_view_folder": 1}}, ] - result = list(module.share_folder(ACCOUNT_ID, "INBOX", share_data)) - - # Should have called delete_acl for user2 - assert len(fake_client.delete_acl_calls) >= 1 - # Should have updated user1 - assert len(fake_client.set_acl_calls) >= 1 - - -def test_share_folder_with_multiple_users(monkeypatch): - """Test sharing a folder with multiple users.""" - module, fake_client = _make_module(monkeypatch) - module.user.login_mail_server = 'owner@example.com' - fake_client.get_acl_result = [] - - def get_acl_after_share(folder_path): - if fake_client.set_acl_calls: - return [ - ('user1@example.com', {'userCanViewFolder': 1, 'userCanReadMails': 1}), - ('user2@example.com', {'userCanViewFolder': 1}) - ] - return [] - - fake_client.get_acl = get_acl_after_share + result = module.put_folder_share(ACCOUNT_ID, "INBOX", users) - share_data = [ - { - "c_email": "user1@example.com", - "rights": {"userCanViewFolder": 1, "userCanReadMails": 1} - }, - { - "c_email": "user2@example.com", - "rights": {"userCanViewFolder": 1} - } - ] - - result = list(module.share_folder(ACCOUNT_ID, "INBOX", share_data)) - - # Should have called set_acl for both users - assert len(fake_client.set_acl_calls) == 2 - identifiers = [call[1] for call in fake_client.set_acl_calls] + assert len(fake_client.set_acl_raw_calls) == 2 + identifiers = [call[1] for call in fake_client.set_acl_raw_calls] assert 'user1@example.com' in identifiers assert 'user2@example.com' in identifiers # =========================================================================== From f4e9abff567aa8f1abe533a700a021a8d17e785d Mon Sep 17 00:00:00 2001 From: tkeriven Date: Thu, 3 Sep 2026 14:30:25 +0200 Subject: [PATCH 6/8] OP#2859 : add API calendar display --- app/api/v1/calendar/schemas/event.py | 4 +++ app/api/v1/contact/ApiContact.py | 31 +++++++++++++++-- app/api/v1/contact/schemas/contact.py | 10 ++++-- app/api/v1/user/ApiUserPreferences.py | 9 +++++ app/api/v1/user/schema/userPreferences.py | 26 +++++++++++++- app/auth/User.py | 9 +++++ .../calendar/InterfaceApiCalendarCalendar.py | 22 ++++++++++-- .../user/InterfaceUserPreferences.py | 20 +++++++++++ app/module/calendar/ModuleCalendar.py | 4 ++- app/module/calendar/source/CalendarSources.py | 7 ++++ app/module/user/ModuleUserProfile.py | 34 +++++++++++++++++++ app/utils/errors.py | 1 + .../test_calendar/test_ModuleCalendarEvent.py | 10 ++++-- 13 files changed, 177 insertions(+), 10 deletions(-) diff --git a/app/api/v1/calendar/schemas/event.py b/app/api/v1/calendar/schemas/event.py index 4bf299c4..ebd374d6 100644 --- a/app/api/v1/calendar/schemas/event.py +++ b/app/api/v1/calendar/schemas/event.py @@ -76,6 +76,10 @@ class CalendarEventQueryArgsSchema(Schema): validate=[validate.Length(max=_SEARCH_MAX_LENGTH), _validate_search], metadata={"description": "Full-text search in title, description and location. Must contain at least 2 non-whitespace characters."}, ) + only_subscribe = fields.Boolean( + load_default=True, + metadata={"description": "When true, only return events from calendars the user marked as subscribed (folders.CALENDAR.* = true)."}, + ) class CalendarEventSchema(Schema): diff --git a/app/api/v1/contact/ApiContact.py b/app/api/v1/contact/ApiContact.py index 3b0ce83d..d36b0ee1 100644 --- a/app/api/v1/contact/ApiContact.py +++ b/app/api/v1/contact/ApiContact.py @@ -207,7 +207,22 @@ class ApiContactList(MethodView): @blp.arguments(ContactSearchQueryArgsSchema, location="query", arg_name="query_args") @collection_paginate(blp, sort_value_set=_SORT_VALUES, can_filter=False) def get(self, query_args: dict, collection_param: CollectionPaginateArgs) -> CustomPaginateResponse: - """List contacts across every address book, with search, sort and pagination.""" + """List contacts across every address book, with search, sort and pagination. + + Returns the contacts of all the address books the current user can access (owned and shared), + each one carrying its ``addressbook_key``. To restrict the list to a single address book, use + ``GET /addressbooks/{key}/contacts``. + + Query parameters: + - ``search``: optional full-text query (minimum 2 non-whitespace characters, otherwise 422). + - ``sort_by``: ``display_name`` (default), ``last_name``, ``first_name``, ``organization``, + ``created_at`` or ``updated_at``. An unknown value falls back to ``display_name``. + - ``sort_order``: ``asc`` (default) or ``desc``. + - ``page`` (default 1) / ``page_size`` (default 20, max 100): pagination. + + The total number of matching contacts is returned in the ``X-Pagination`` response header, + not in the body. The response body holds the current page under ``data.contacts``. + """ logger_api.debug("GET /contacts user=%s params=%s", g.user.uid, collection_param) interface: InterfaceApiContactContact = g.inter return interface.get_contacts(None, collection_param, search=query_args.get("search")) @@ -220,7 +235,19 @@ class ApiContactAutocomplete(MethodView): @blp.response(200, ContactAutocompleteResponseSchema) @blp.arguments(ContactAutocompleteQueryArgsSchema, location="query", arg_name="query_args") def get(self, query_args: dict) -> ResponseReturnValue: - """Return recipient suggestions for the ``q`` query string.""" + """Suggest recipients (contacts and distribution lists) for a partially typed name or email. + + Meant for the recipient field of a mail composer. The search spans all the address books of the + current user and returns lightweight suggestions, not full contact cards: + - ``type = "contact"``: one suggestion per email address of a matching contact (``name``, + ``email``, ``contact_key`` and the ``address_book`` it belongs to). + - ``type = "list"``: a matching distribution list, with ``list_key``, ``member_count`` and its + resolved ``members`` (``name`` + ``email``) instead of a single ``email``. + + Contacts and lists are each capped to a fixed number of results. When ``q`` is shorter than the + domain's minimum autocompletion length (``SOGO_D_AUTOCOMPLETION_MIN_LEN``), the endpoint returns + an empty ``suggestions`` list rather than an error. The ``q`` parameter is required (422 if missing). + """ logger_api.debug("GET /contacts/autocomplete user=%s q=%s", g.user.uid, query_args.get("q")) interface: InterfaceApiContactContact = g.inter return interface.autocomplete(query_args["q"]) diff --git a/app/api/v1/contact/schemas/contact.py b/app/api/v1/contact/schemas/contact.py index 25c4bf47..4f10b5f9 100644 --- a/app/api/v1/contact/schemas/contact.py +++ b/app/api/v1/contact/schemas/contact.py @@ -128,7 +128,10 @@ class ContactSearchQueryArgsSchema(Schema): """Query string for the contact list endpoints (search only; pagination is handled separately).""" search = fields.String(load_default=None, allow_none=True, validate=validate_search, - metadata={"description": "Full-text query (min 2 non-whitespace characters)."}) + metadata={ + "description": "Full-text query matched against the contact fields " + "(min 2 non-whitespace characters). Omit to list everything.", + "example": "doe"}) class ContactListDataSchema(Schema): @@ -154,7 +157,10 @@ class ContactResponseSchema(ApiBaseResponse): class ContactAutocompleteQueryArgsSchema(Schema): """Query string for the recipient autocompletion endpoint.""" - q = fields.String(required=True, metadata={"description": "Partial name or email to autocomplete."}) + q = fields.String(required=True, metadata={ + "description": "Partial name or email typed by the user. Shorter than the domain minimum " + "autocompletion length returns an empty suggestion list.", + "example": "john"}) class SuggestionMemberSchema(Schema): diff --git a/app/api/v1/user/ApiUserPreferences.py b/app/api/v1/user/ApiUserPreferences.py index 4c23bf03..6ffc4784 100644 --- a/app/api/v1/user/ApiUserPreferences.py +++ b/app/api/v1/user/ApiUserPreferences.py @@ -71,6 +71,15 @@ def get(self) -> ResponseReturnValue: interface_api: InterfaceUserPreferences = g.inter return interface_api.get_user_folders() + @blp.arguments(sch.UserPreferencesFoldersPatch, example=sch.UserPreferencesFoldersPatch.example(), error_status_code=400) + @blp.response(200) + def patch(self, new_data: dict) -> ResponseReturnValue: + """ + Update a single folder key's value in the user's folders structure + """ + interface_api: InterfaceUserPreferences = g.inter + return interface_api.update_folder_value(new_data["resource"], new_data["id"], new_data["value"]) + # @blp.route("/") # class ApiUserPreferencesPart(MethodView): diff --git a/app/api/v1/user/schema/userPreferences.py b/app/api/v1/user/schema/userPreferences.py index c55ae6e9..4fe25ab8 100644 --- a/app/api/v1/user/schema/userPreferences.py +++ b/app/api/v1/user/schema/userPreferences.py @@ -1,4 +1,4 @@ -from marshmallow import Schema, fields +from marshmallow import Schema, fields, validate from app.utils.api.ApiBaseResponse import ApiBaseResponse @@ -86,4 +86,28 @@ def example(cls) -> dict: "SOGO_U_LANGUAGE": "French", } } + } + +class UserPreferencesFoldersPatch(Schema): + """ + Schema of the body expected for PATCH /preferences/folders + + Updates a single folder key's boolean value within the folders column (CALENDAR or ADDRESSBOOKS) + """ + resource = fields.String(required=True, validate=validate.OneOf(["CALENDAR", "ADDRESSBOOKS"])) + id = fields.String(required=True) + value = fields.Boolean(required=True) + + @classmethod + def example(cls) -> dict: + """ + Example of data for the patch request + + :return: Example data + :rtype: dict + """ + return { + "resource": "CALENDAR", + "id": "3c4b0bc9-3aab-4243-abb2-75a5edc8c239", + "value": True } \ No newline at end of file diff --git a/app/auth/User.py b/app/auth/User.py index a7efa4ca..c16dab38 100644 --- a/app/auth/User.py +++ b/app/auth/User.py @@ -127,6 +127,15 @@ def __init__(self, uid:str, password:str= "", cn:str= "", domain:str= "", is_dom #DEPRECATED but legacy, only work with imap self.imap_host: str = "" + @property + def folders(self) -> dict: + """ + Shortcut to the folders stored in the user profile (column ``folders`` of TABLE_USER). + + :return: The user's folders + :rtype: dict + """ + return self.profile.folders def get_user_session(self) -> dict: """ diff --git a/app/interface/calendar/InterfaceApiCalendarCalendar.py b/app/interface/calendar/InterfaceApiCalendarCalendar.py index 1e66bade..2a973244 100644 --- a/app/interface/calendar/InterfaceApiCalendarCalendar.py +++ b/app/interface/calendar/InterfaceApiCalendarCalendar.py @@ -333,7 +333,8 @@ def get_events(self, key: str | None, query_args: dict[str, Any]) -> tuple[dict[ When no dates are provided and there is no search query, defaults to the current calendar day (UTC). :param key: Calendar key, or None to query all user calendars. - :param query_args: Parsed query arguments: ``start_date_time``, ``end_date_time``, ``search`` (all optional). + :param query_args: Parsed query arguments: ``start_date_time``, ``end_date_time``, ``search``, + ``only_subscribe`` (all optional). :return: API envelope with ``events`` list and ``total_count``, plus HTTP status code. """ try: @@ -345,13 +346,30 @@ def get_events(self, key: str | None, query_args: dict[str, Any]) -> tuple[dict[ start = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc) end = datetime(today.year, today.month, today.day, 23, 59, 59, tzinfo=timezone.utc) calendar_user: CalendarUser = self._calendar_user_for(key) if key else CalendarUser(user=self.user, owner=self.user) - events: list[CalEvent] = self.module.get_all_events(calendar_user, start, end, search, key) + subscribed_keys: set[str] | None = self._subscribed_calendar_keys() if query_args.get("only_subscribe") else None + events: list[CalEvent] = self.module.get_all_events(calendar_user, start, end, search, key, subscribed_keys) event_list: list[dict[str, Any]] = self._events_serializer.serialize(events) return create_api_base_response({"events": event_list, "total_count": len(event_list)}) except RequestException as ex: logger_api.error("get_events failed for user %s, calendar %s: %s", self.user.uid, key, ex) return create_api_base_response(None, ex.error) + def _subscribed_calendar_keys(self) -> set[str]: + """Return the calendar keys marked True under folders.CALENDAR (any of OWNER/SUBS/EXT/...). + + ``folders`` is the raw JSON blob from sogo_user_profile: ``{"CALENDAR": {"OWNER": {key: bool, ...}, ...}}``. + Only the "CALENDAR" branch is relevant here; every sub-group is scanned generically so a + calendar counts as subscribed regardless of which group (owned, shared, external) it lives in. + """ + calendar_groups: dict[str, Any] = self.user.folders.get("CALENDAR", {}) + return { + calendar_key + for group in calendar_groups.values() + if isinstance(group, dict) + for calendar_key, is_subscribed in group.items() + if is_subscribed is True + } + # # Tasks # diff --git a/app/interface/user/InterfaceUserPreferences.py b/app/interface/user/InterfaceUserPreferences.py index 0331512e..4f48aa00 100644 --- a/app/interface/user/InterfaceUserPreferences.py +++ b/app/interface/user/InterfaceUserPreferences.py @@ -55,6 +55,26 @@ def get_user_folders(self) -> tuple[dict, int]: return create_api_base_response(folders) + def update_folder_value(self, resource: str, folder_id: str, value: bool) -> tuple[dict, int]: + """ + Update a single folder key's boolean value in the user's folders structure (CALENDAR or ADDRESSBOOKS) + + :param resource: Resource type - "CALENDAR" or "ADDRESSBOOKS" + :type resource: str + :param folder_id: Unique id of the folder to update + :type folder_id: str + :param value: New boolean value to set + :type value: bool + :return: Tuple containing response dict and HTTP status code + :rtype: tuple[dict, int] + """ + try: + folders = self.module_user_profile.update_folder_value(self.user.uid, resource, folder_id, value) + except RequestException as ex: + return create_api_base_response(None, ex.error) + + return create_api_base_response(folders) + def get_partial_preferences(self, subparent:str) -> tuple[dict, int]: """Get partial user preferences for a specific subparent diff --git a/app/module/calendar/ModuleCalendar.py b/app/module/calendar/ModuleCalendar.py index bd437a99..6f0ad204 100644 --- a/app/module/calendar/ModuleCalendar.py +++ b/app/module/calendar/ModuleCalendar.py @@ -386,17 +386,19 @@ def get_all_events( end: datetime | None, search: str | None, key: str | None = None, + subscribed_keys: set[str] | None = None, ) -> list[CalEvent]: """Return events within [start, end], optionally restricted to a single calendar. When key is None, events from all user calendars are merged. The date-range limit is bypassed when a search query is present. + When subscribed_keys is not None, only events whose calendar is in that set are returned. """ if search is None and start is not None and end is not None: if (end - start) > timedelta(days=MAX_EVENT_FETCH_DAYS): raise RequestException(error=err.ERROR_CALENDAR_DATE_RANGE_TOO_LARGE) try: - events: list[CalEvent] = self._sources.get_all_events(calendar_user.owner.uid, start, end, search, key) + events: list[CalEvent] = self._sources.get_all_events(calendar_user.owner.uid, start, end, search, key, subscribed_keys) logger_calendar.debug("returned %d events (calendar=%s)", len(events), key or "all") return self._sanitize_listing(calendar_user, events) except RequestException: diff --git a/app/module/calendar/source/CalendarSources.py b/app/module/calendar/source/CalendarSources.py index 46dcaa7d..791cf216 100644 --- a/app/module/calendar/source/CalendarSources.py +++ b/app/module/calendar/source/CalendarSources.py @@ -147,19 +147,26 @@ def get_all_events( end: datetime | None = None, search: str | None = None, calendar_key: str | None = None, + subscribed_keys: set[str] | None = None, ) -> list[CalEvent]: """Return events for user_uid, optionally restricted to a single calendar. When calendar_key is None, events from all user calendars are merged and sorted. + When subscribed_keys is not None, calendars whose key is not in that set are skipped + entirely (e.g. ``only_subscribe`` filtering from folders.CALENDAR). Raises ERROR_CALENDAR_NOT_FOUND if calendar_key is given but does not exist. """ if calendar_key is not None: source = self.get_by_key(user_uid, calendar_key) if source is None: raise RequestException(error=err.ERROR_CALENDAR_NOT_FOUND) + if subscribed_keys is not None and calendar_key not in subscribed_keys: + return [] return source.get_all_events(start, end, search) events: list[CalEvent] = [] for source in self.get_all(user_uid): + if subscribed_keys is not None and source.calendar.key not in subscribed_keys: + continue events.extend(source.get_all_events(start, end, search)) events.sort(key=lambda e: e.require_date_start) return events diff --git a/app/module/user/ModuleUserProfile.py b/app/module/user/ModuleUserProfile.py index 7cea50e0..ee9a5caa 100644 --- a/app/module/user/ModuleUserProfile.py +++ b/app/module/user/ModuleUserProfile.py @@ -283,6 +283,40 @@ def remove_folder_key(self, uid: str, folder_type: str, key: str, owner_key: str self._update_user_column(uid, tbl.COL_USER_FOLDERS.name, current_folders) + def update_folder_value(self, uid: str, resource: str, folder_id: str, value: bool) -> dict: + """ + Update the boolean value of a single folder key within the folders column (CALENDAR or ADDRESSBOOKS). + + Searches every owner-type sub-section (OWNER, SUBS, EXT, ...) of the given resource for folder_id + and updates the first match found; folder_id is expected to be unique across the whole structure. + + :param uid: User unique identifier + :type uid: str + :param resource: Resource type - "CALENDAR" or "ADDRESSBOOKS" + :type resource: str + :param folder_id: Unique id of the folder to update + :type folder_id: str + :param value: New boolean value to set + :type value: bool + :return: The updated folders dictionary + :rtype: dict + :raises RequestException: If user profile not found or folder_id not found for this resource + :raises AggravatedException: If multiple user profiles found + """ + logger_user_profile.debug("Updating folder value for uid: %s, resource: %s, id: %s, value: %s", + uid, resource, folder_id, value) + + current_folders = self._get_user_column(uid, tbl.COL_USER_FOLDERS.name) + + for section in current_folders.get(resource, {}).values(): + if folder_id in section: + section[folder_id] = value + self._update_user_column(uid, tbl.COL_USER_FOLDERS.name, current_folders) + return current_folders + + logger_user_profile.error("Folder key not found for uid: %s, resource: %s, id: %s", uid, resource, folder_id) + raise RequestException(err.ERROR_FOLDER_KEY_NOT_FOUND.m, err.ERROR_FOLDER_KEY_NOT_FOUND) + def _get_user_column(self, uid: str, field_name: str) -> Any: """ Generic method to get a specific field from user profile diff --git a/app/utils/errors.py b/app/utils/errors.py index ba630723..d30fb0d0 100644 --- a/app/utils/errors.py +++ b/app/utils/errors.py @@ -185,6 +185,7 @@ def __init__(self, c:str, m:str, h:int = HTTPStatus.INTERNAL_SERVER_ERROR): #Preferences ERROR_PREF_UNKNOWN_SUB = E("S000340", "Subparent of User Settings does not exist", HTTPStatus.BAD_REQUEST) +ERROR_FOLDER_KEY_NOT_FOUND = E("S000341", "Folder Key Not Found In Folders Preferences", HTTPStatus.NOT_FOUND) #Delegations ERROR_DELEGATION_NOT_FOUND = E("S000350", "Delegation Not Found", HTTPStatus.NOT_FOUND) diff --git a/tests/test_calendar/test_ModuleCalendarEvent.py b/tests/test_calendar/test_ModuleCalendarEvent.py index c01c1609..9ac6da50 100644 --- a/tests/test_calendar/test_ModuleCalendarEvent.py +++ b/tests/test_calendar/test_ModuleCalendarEvent.py @@ -119,13 +119,19 @@ def _require_event(uid, event_key): sources_mock.require_event.side_effect = _require_event - def _get_events(uid, start, end, search, calendar_key=None): + def _get_events(uid, start, end, search, calendar_key=None, subscribed_keys=None): if calendar_key is not None: source = sources.get(calendar_key) if source is None: raise RequestException(error=err.ERROR_CALENDAR_NOT_FOUND) + if subscribed_keys is not None and calendar_key not in subscribed_keys: + return [] return source.get_all_events(start, end, search) - return [e for s in sources.values() for e in s.get_all_events(start, end, search)] + return [ + e for key, s in sources.items() + if subscribed_keys is None or key in subscribed_keys + for e in s.get_all_events(start, end, search) + ] sources_mock.get_all_events.side_effect = _get_events module._sources = sources_mock From e0b451865661a82b0597d057455a2ed94277aa5a Mon Sep 17 00:00:00 2001 From: tkeriven Date: Wed, 23 Sep 2026 14:19:49 +0200 Subject: [PATCH 7/8] wip: events without permission are not displayed --- app/module/calendar/acl/CalendarAclEngine.py | 9 +++++++-- tests/test_calendar/test_CalendarAclEngine.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/module/calendar/acl/CalendarAclEngine.py b/app/module/calendar/acl/CalendarAclEngine.py index 073a71e9..d47303a1 100644 --- a/app/module/calendar/acl/CalendarAclEngine.py +++ b/app/module/calendar/acl/CalendarAclEngine.py @@ -3,7 +3,9 @@ import dataclasses from typing import TYPE_CHECKING +from app.auth.User import User from app.module.calendar.model.CalendarPermissions import CalendarPermissions +from app.module.calendar.model.CalendarUser import CalendarUser from app.module.calendar.model.enums.CalendarPermissionAction import CalendarPermissionAction from app.module.calendar.model.enums.CalendarShareLevel import CalendarShareLevel from app.module.calendar.model.enums.CalendarSourceType import CalendarSourceType @@ -15,7 +17,6 @@ from app.factory.share.shareCalendar import ShareCalendar from app.module.calendar.model.CalCalendar import CalCalendar from app.module.calendar.model.CalEvent import CalEvent - from app.module.calendar.model.CalendarUser import CalendarUser _BUSY_TITLE = "Busy" @@ -153,7 +154,11 @@ def sanitize_listing( result.append(item) continue if calendar_key not in permissions_cache: - permissions_cache[calendar_key] = self.get_permissions(calendar, calendar_user) + owner_calendar_user: CalendarUser = ( + calendar_user if calendar.user_uid == calendar_user.owner.uid + else CalendarUser(user=calendar_user.user, owner=User(uid=calendar.user_uid)) + ) + permissions_cache[calendar_key] = self.get_permissions(calendar, owner_calendar_user) result.extend(self.sanitize_events([item], permissions_cache[calendar_key])) return result diff --git a/tests/test_calendar/test_CalendarAclEngine.py b/tests/test_calendar/test_CalendarAclEngine.py index fffed928..a90c7b01 100644 --- a/tests/test_calendar/test_CalendarAclEngine.py +++ b/tests/test_calendar/test_CalendarAclEngine.py @@ -74,6 +74,26 @@ def test_sanitize_listing_non_owner_hidden(): assert result == [] +def test_sanitize_listing_cross_calendar_self_acting_user_not_treated_as_owner(): + """A self-acting CalendarUser (user == owner, e.g. GET /events with no calendar key) must not + be treated as the owner of every calendar in the listing: each item's real calendar owner + (calendar.user_uid) has to be substituted before resolving permissions, otherwise a calendar + merely shared with the acting user would wrongly grant full owner permissions.""" + engine = CalendarAclEngine() + items = [_make_event(key="e1", calendar_key="cal-key")] + self_acting = _make_calendar_user("bob@test") + result = engine.sanitize_listing(self_acting, items, {"cal-key": _make_cal()}) + assert result == [] + + +def test_sanitize_listing_cross_calendar_keeps_events_for_own_calendar(): + engine = CalendarAclEngine() + items = [_make_event(key="e1", calendar_key="cal-key")] + self_acting = _make_calendar_user("owner@test") + result = engine.sanitize_listing(self_acting, items, {"cal-key": _make_cal()}) + assert result == items + + def test_sanitize_listing_unknown_calendar_passthrough(): engine = CalendarAclEngine() items = [_make_event(key="e1", calendar_key="other-key")] From a9895344e2605cb3bdba00850f7ff6ccfdf2dfef Mon Sep 17 00:00:00 2001 From: tkeriven Date: Wed, 23 Sep 2026 15:03:43 +0200 Subject: [PATCH 8/8] fix advanced search --- app/api/v1/mail/ApiMailMailbox.py | 7 +-- app/api/v1/mail/schemas/mailbox.py | 12 ++--- app/manager/mail/ClientImap.py | 41 ++++++++++++---- .../test_manager/test_mail/test_clientImap.py | 48 +++++++++++++++---- 4 files changed, 82 insertions(+), 26 deletions(-) diff --git a/app/api/v1/mail/ApiMailMailbox.py b/app/api/v1/mail/ApiMailMailbox.py index d40e031a..1b8cbf50 100644 --- a/app/api/v1/mail/ApiMailMailbox.py +++ b/app/api/v1/mail/ApiMailMailbox.py @@ -228,9 +228,10 @@ def post(self, search_params: dict, collection_param: "CollectionPaginateArgs", * **include_subfolders**: bool, default True - when True, also search the subfolders of each folder listed in "folders"; when False, search only the exact folders listed. Ignored when "folders" is empty or ["all"]. * **date_range**: dict, date range for the search (e.g. {"from": "2023-01-01", "to": "2023-01-31"}) * **has_attachments**: bool, whether to search for emails with attachments - * **to**: str, email address to search for in either the recipient (To) or copy (Cc) headers - * **bcc**: str, blind copy (Bcc) email address to search for - * **from**: list[str], list of sender email addresses to search for + * **to**: list[str], recipient email addresses to search for in either the recipient (To) or copy (Cc) headers. + A mail matches if any of the given addresses appears in either header. + * **bcc**: list[str], blind copy (Bcc) email addresses to search for. A mail matches if any of the given addresses appears in the Bcc header. + * **from**: list[str], list of sender email addresses to search for. A mail matches if its sender is any of the given addresses. * **subject** : str, keywords to search for in the email subject * **attachment_type**: list[str], list of attachment types to search for (e.g. ["pdf", "jpg"]) * **is_read**: bool, whether to search for read or unread emails diff --git a/app/api/v1/mail/schemas/mailbox.py b/app/api/v1/mail/schemas/mailbox.py index 1ada6f5c..628f0e0a 100644 --- a/app/api/v1/mail/schemas/mailbox.py +++ b/app/api/v1/mail/schemas/mailbox.py @@ -713,9 +713,9 @@ class MailboxSearchSchema(Schema): metadata={"description": "Logical operator combining the search criteria below: 'AND' (default) requires every provided criterion to match, 'OR' requires at least one to match"} ) text = fields.String(required=False, allow_none=True, load_default=None, metadata={"description": "Full-text search in body and headers"}) - from_ = fields.String(required=False, allow_none=True, load_default=None, data_key="from", metadata={"description": "Filter by sender email address"}) - to = fields.String(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by recipient email address (matches either the To or the Cc header)"}) - bcc = fields.String(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by Bcc recipient email address"}) + from_ = fields.List(fields.String(), required=False, allow_none=True, load_default=None, data_key="from", metadata={"description": "Filter by sender email address(es). A mail matches if its sender is any of the given addresses"}) + to = fields.List(fields.String(), required=False, allow_none=True, load_default=None, metadata={"description": "Filter by recipient email address(es) (matches either the To or the Cc header). A mail matches if any of the given addresses appears in either header"}) + bcc = fields.List(fields.String(), required=False, allow_none=True, load_default=None, metadata={"description": "Filter by Bcc recipient email address(es). A mail matches if its Bcc header contains any of the given addresses"}) subject = fields.String(required=False, allow_none=True, load_default=None, metadata={"description": "Filter by subject (substring match)"}) has_attachment = fields.Boolean(required=False, allow_none=True, load_default=None, metadata={"description": "Filter mails that have (or don't have) attachments"}) attachment_type = fields.List(fields.String(), required=False, allow_none=True, load_default=None, metadata={"description": "Filter by attachment file extensions (e.g. ['pdf', 'jpg'])"}) @@ -737,9 +737,9 @@ def example(cls) -> dict: return { "operator": "AND", "text": "contrat urgent", - "from": "customer@entreprise.com", - "to": "jdoe@domaine.com", - "bcc": "hidden@domaine.com", + "from": ["customer@entreprise.com", "provider@entreprise.com"], + "to": ["jdoe@domaine.com"], + "bcc": ["hidden@domaine.com"], "subject": "Projet X", "has_attachment": True, "attachment_type": ["pdf", "jpg"], diff --git a/app/manager/mail/ClientImap.py b/app/manager/mail/ClientImap.py index ff58bd95..4ae81ed3 100644 --- a/app/manager/mail/ClientImap.py +++ b/app/manager/mail/ClientImap.py @@ -178,6 +178,24 @@ def _combine_imap_search_or(criteria: list[str]) -> str: return f"OR {criteria[0]} {rest}" +def _group_imap_search_parts_or(parts: list[str]) -> str: + """Group several IMAP search-key strings for the same multi-valued field with OR. + + Used for fields accepting a list of values (e.g. several ``from`` addresses), + where a mail should match if it satisfies *any* of the values. The combined + OR-key is parenthesized when it has more than one part so it stays atomic once + embedded among sibling field groups, whatever the top-level operator (AND/OR) is. + + :param parts: IMAP search-key strings for a single field (e.g. one per address). + :type parts: list[str] + :return: A single search-key string. + :rtype: str + """ + if len(parts) == 1: + return parts[0] + return "(" + _combine_imap_search_or(parts) + ")" + + class ImapFolder: """ Simple class to parse folder response and store useful values @@ -2181,9 +2199,11 @@ def build_search_criteria(self, search_params: dict, deleted: bool) -> str: Each populated field produces one independent search-key "group". Groups are then combined using ``search_params["operator"]``: - ``to`` matches a mail whose ``To`` *or* ``Cc`` header contains the address - (OR-ed across the two headers). ``bcc`` only matches against the ``Bcc`` - header. + ``from``, ``to`` and ``bcc`` each accept a list of addresses: a mail matches + the field if it matches *any* of the given addresses (OR-ed across the list), + regardless of the top-level ``operator``. ``to`` additionally matches a mail + whose ``To`` *or* ``Cc`` header contains the address (OR-ed across the two + headers). ``bcc`` only matches against the ``Bcc`` header. * "AND" (default): groups are simply space-joined (IMAP's implicit AND). * "OR": groups are combined with a right-nested IMAP ``OR`` operator, so that @@ -2221,16 +2241,19 @@ def build_search_criteria(self, search_params: dict, deleted: bool) -> str: field_groups.append(f'TEXT "{search_params["text"]}"') if search_params.get("from_"): - escaped = escape_imap_string(search_params["from_"]) - field_groups.append(f'FROM "{escaped}"') + from_parts = [f'FROM "{escape_imap_string(addr)}"' for addr in search_params["from_"]] + field_groups.append(_group_imap_search_parts_or(from_parts)) if search_params.get("to"): - escaped = escape_imap_string(search_params["to"]) - field_groups.append(f'(OR TO "{escaped}" CC "{escaped}")') + to_parts = [ + f'(OR TO "{escape_imap_string(addr)}" CC "{escape_imap_string(addr)}")' + for addr in search_params["to"] + ] + field_groups.append(_group_imap_search_parts_or(to_parts)) if search_params.get("bcc"): - escaped = escape_imap_string(search_params["bcc"]) - field_groups.append(f'BCC "{escaped}"') + bcc_parts = [f'BCC "{escape_imap_string(addr)}"' for addr in search_params["bcc"]] + field_groups.append(_group_imap_search_parts_or(bcc_parts)) if search_params.get("subject"): field_groups.append(f'SUBJECT "{search_params["subject"]}"') diff --git a/tests/test_manager/test_mail/test_clientImap.py b/tests/test_manager/test_mail/test_clientImap.py index c6d3256d..b9404c5d 100644 --- a/tests/test_manager/test_mail/test_clientImap.py +++ b/tests/test_manager/test_mail/test_clientImap.py @@ -1855,28 +1855,28 @@ class TestBuildSearchCriteria: def test_default_operator_is_and(self): client = make_client() criteria = client.build_search_criteria( - {"subject": "Projet X", "from_": "a@b.com"}, deleted=False + {"subject": "Projet X", "from_": ["a@b.com"]}, deleted=False ) assert criteria == '(NOT DELETED FROM "a@b.com" SUBJECT "Projet X")' def test_explicit_and_operator_same_as_default(self): client = make_client() criteria = client.build_search_criteria( - {"operator": "AND", "subject": "Projet X", "from_": "a@b.com"}, deleted=False + {"operator": "AND", "subject": "Projet X", "from_": ["a@b.com"]}, deleted=False ) assert criteria == '(NOT DELETED FROM "a@b.com" SUBJECT "Projet X")' def test_or_operator_combines_two_fields(self): client = make_client() criteria = client.build_search_criteria( - {"operator": "OR", "subject": "Projet X", "from_": "a@b.com"}, deleted=False + {"operator": "OR", "subject": "Projet X", "from_": ["a@b.com"]}, deleted=False ) assert criteria == '(NOT DELETED OR FROM "a@b.com" SUBJECT "Projet X")' def test_or_operator_combines_more_than_two_fields(self): client = make_client() criteria = client.build_search_criteria( - {"operator": "OR", "subject": "Projet X", "from_": "a@b.com", "is_read": False}, + {"operator": "OR", "subject": "Projet X", "from_": ["a@b.com"], "is_read": False}, deleted=False, ) assert criteria == '(NOT DELETED OR FROM "a@b.com" (OR SUBJECT "Projet X" UNSEEN))' @@ -1891,14 +1891,14 @@ def test_or_operator_with_single_field_has_no_or_keyword(self): def test_deleted_true_applies_no_deleted_filter_regardless_of_operator(self): client = make_client() criteria = client.build_search_criteria( - {"operator": "OR", "subject": "Projet X", "from_": "a@b.com"}, deleted=True + {"operator": "OR", "subject": "Projet X", "from_": ["a@b.com"]}, deleted=True ) assert criteria == '(OR FROM "a@b.com" SUBJECT "Projet X")' def test_or_operator_combines_to_with_another_field(self): client = make_client() criteria = client.build_search_criteria( - {"operator": "OR", "to": "x@y.com", "subject": "Projet X"}, + {"operator": "OR", "to": ["x@y.com"], "subject": "Projet X"}, deleted=False, ) assert criteria == ( @@ -1908,17 +1908,49 @@ def test_or_operator_combines_to_with_another_field(self): def test_to_field_matches_to_or_cc_header(self): client = make_client() criteria = client.build_search_criteria( - {"to": "x@y.com"}, deleted=False + {"to": ["x@y.com"]}, deleted=False ) assert criteria == '(NOT DELETED (OR TO "x@y.com" CC "x@y.com"))' def test_bcc_field(self): client = make_client() criteria = client.build_search_criteria( - {"bcc": "x@y.com"}, deleted=False + {"bcc": ["x@y.com"]}, deleted=False ) assert criteria == '(NOT DELETED BCC "x@y.com")' + def test_from_field_multiple_addresses_are_ored(self): + client = make_client() + criteria = client.build_search_criteria( + {"from_": ["a@b.com", "c@d.com"]}, deleted=False + ) + assert criteria == '(NOT DELETED (OR FROM "a@b.com" FROM "c@d.com"))' + + def test_from_field_more_than_two_addresses_are_ored(self): + client = make_client() + criteria = client.build_search_criteria( + {"from_": ["a@b.com", "c@d.com", "e@f.com"]}, deleted=False + ) + assert criteria == ( + '(NOT DELETED (OR FROM "a@b.com" (OR FROM "c@d.com" FROM "e@f.com")))' + ) + + def test_to_field_multiple_addresses_are_ored(self): + client = make_client() + criteria = client.build_search_criteria( + {"to": ["a@b.com", "c@d.com"]}, deleted=False + ) + assert criteria == ( + '(NOT DELETED (OR (OR TO "a@b.com" CC "a@b.com") (OR TO "c@d.com" CC "c@d.com")))' + ) + + def test_bcc_field_multiple_addresses_are_ored(self): + client = make_client() + criteria = client.build_search_criteria( + {"bcc": ["a@b.com", "c@d.com"]}, deleted=False + ) + assert criteria == '(NOT DELETED (OR BCC "a@b.com" BCC "c@d.com"))' + def test_no_user_criteria_and_deleted_true_returns_all(self): client = make_client() assert client.build_search_criteria({}, deleted=True) == "ALL"