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..3f96ea48 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 @@ -71,6 +72,8 @@ class CalendarSchema(Schema): 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) + 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) @@ -158,3 +161,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/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/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/mail/ApiMailMail.py b/app/api/v1/mail/ApiMailMail.py index 73492451..0c9a52f0 100644 --- a/app/api/v1/mail/ApiMailMail.py +++ b/app/api/v1/mail/ApiMailMail.py @@ -140,6 +140,8 @@ def post(self, data: dict, account_id: str, folder_name: str) -> ResponseReturnV * **ham**: Mark the selected mails as not spam. * **copy**: Copy the selected mails to another folder. The destination folder name must be provided in the ``data`` field as a string. * **delete**: Delete the selected mails, following the user's mail delete behavior preference. + * **illegal**: Report the selected mails as illegal content and move them to the Junk folder. + * **phishing**: Report the selected mails as phishing and move them to the Junk folder. :param data: The batch action data containing 'uids', 'action' and optional 'data' field :type data: dict @@ -229,6 +231,8 @@ def post(self, data: dict, account_id: str, folder_name: str, mail_uid: str) -> * **ham**: Mark the mail as not spam. * **copy**: Copy the mail to another folder. The destination folder name must be provided in the ``data`` field as a string. * **delete**: Delete the mail, following the user's mail delete behavior preference. + * **illegal**: Report the mail as illegal content and move it to the Junk folder. + * **phishing**: Report the mail as phishing and move it to the Junk folder. :param data: The action data containing 'action' and optional 'data' field :type data: dict diff --git a/app/api/v1/mail/ApiMailMailbox.py b/app/api/v1/mail/ApiMailMailbox.py index 2bfbc290..81b8d594 100644 --- a/app/api/v1/mail/ApiMailMailbox.py +++ b/app/api/v1/mail/ApiMailMailbox.py @@ -18,6 +18,8 @@ DelegationResponseSchema, MailboxPurgeSchema, MailboxPurgeResponseSchema, + MailboxBatchActionSchema, + MailboxBatchActionResponseSchema, ) if TYPE_CHECKING: @@ -140,6 +142,51 @@ def post(self, data: dict, account_id: str) -> ResponseReturnValue: return interface.create_mailbox_delegate(account_id, data) +@blp.route("//batch-action") +class ApiMailBoxesAccountBatchAction(MethodView): + """ + Resource: Batch actions across the whole mailbox + """ + @blp.arguments(MailboxBatchActionSchema, example=MailboxBatchActionSchema.example(), error_status_code=400) + @blp.response(200, MailboxBatchActionResponseSchema, example=MailboxBatchActionResponseSchema.example()) + def post(self, data: dict, account_id: str) -> ResponseReturnValue: + """Perform an action (tag, untag, move, spam, ham, copy) on mails from several folders of the account at once. + + Behaves like the per-folder batch action endpoint, except that ``uids`` maps folder names + to their list of mail UIDs, so mails from multiple folders can be processed in a single call. + Each folder is processed independently: a failure on one folder does not prevent the others + from being processed, and the per-folder outcome is reported in the response's ``results`` + and ``errors`` fields. + + **Supported actions:** + + * **tag**: Add one or more tags to the selected mails. Tags are provided in the ``data`` field as a list of strings. + * **untag**: Remove one or more tags from the selected mails. Tags to remove are provided in the ``data`` field as a list of strings. + * **move**: Move the selected mails to another folder. The destination folder name must be provided in the ``data`` field as a string. + * **spam**: Mark the selected mails as spam. + * **ham**: Mark the selected mails as not spam. + * **copy**: Copy the selected mails to another folder. The destination folder name must be provided in the ``data`` field as a string. + * **delete**: Delete the selected mails, following the user's mail delete behavior preference. + * **illegal**: Report the selected mails as illegal content and move them to the Junk folder. + * **phishing**: Report the selected mails as phishing and move them to the Junk folder. + + :param data: The batch action data containing 'uids' (folder name -> list of uids), 'action' and optional 'data' field + :type data: dict + :param account_id: The account identifier + :type account_id: str + :return: A response indicating the per-folder result of the action + :rtype: ResponseReturnValue + """ + logger_api.debug( + "Calling ApiMailBoxesAccountBatchAction.post for account_id: %s, uids: %s with action: %s", + account_id, + data["uids"], + data["action"] + ) + interface: InterfaceApiMailMailbox = g.inter + return interface.mailbox_batch_action(account_id, data) + + @blp.route("//purge") class ApiMailBoxesAccountPurge(MethodView): """ diff --git a/app/api/v1/mail/schemas/mail.py b/app/api/v1/mail/schemas/mail.py index 96e39bb8..321d04c2 100644 --- a/app/api/v1/mail/schemas/mail.py +++ b/app/api/v1/mail/schemas/mail.py @@ -52,7 +52,7 @@ class MailActionSchema(Schema): """ action = fields.String( required=True, - validate=validate.OneOf(['tag', 'untag', 'move', 'spam', 'ham', 'copy', 'delete']) + validate=validate.OneOf(['tag', 'untag', 'move', 'spam', 'ham', 'copy', 'delete', 'illegal', 'phishing']) ) data = fields.Raw(required=False, allow_none=True) @@ -76,7 +76,7 @@ class MailBatchActionSchema(Schema): uids = fields.List(fields.Integer(), required=True, validate=validate.Length(min=1)) action = fields.String( required=True, - validate=validate.OneOf(['tag', 'untag', 'move', 'spam', 'ham', 'copy', 'delete']) + validate=validate.OneOf(['tag', 'untag', 'move', 'spam', 'ham', 'copy', 'delete', 'illegal', 'phishing']) ) data = fields.Raw(required=False, allow_none=True) diff --git a/app/api/v1/mail/schemas/mailbox.py b/app/api/v1/mail/schemas/mailbox.py index a53274f9..99bfd0e4 100644 --- a/app/api/v1/mail/schemas/mailbox.py +++ b/app/api/v1/mail/schemas/mailbox.py @@ -583,6 +583,67 @@ def example(cls) -> dict: } +class MailboxBatchActionSchema(Schema): + """ + Schema for POST /mailboxes//batch-action - Perform an action on multiple mails + spanning multiple folders of the same account in a single call. + """ + uids = fields.Dict( + keys=fields.String(), + values=fields.List(fields.Integer(), validate=validate.Length(min=1)), + required=True, + validate=validate.Length(min=1) + ) + action = fields.String( + required=True, + validate=validate.OneOf(['tag', 'untag', 'move', 'spam', 'ham', 'copy', 'delete', 'illegal', 'phishing']) + ) + data = fields.Raw(required=False, allow_none=True) + + @classmethod + def example(cls) -> dict: + """Example data for mailbox batch action. + + :return: Example mailbox batch action payload + :rtype: dict + """ + return { + "uids": { + "INBOX": [42, 43, 27, 21], + "Trash": [42, 43] + }, + "action": "tag", + "data": ["important"] + } + + +class MailboxBatchActionResponseSchema(ApiBaseResponse): + """ + Schema for POST /mailboxes//batch-action response + """ + data = fields.Dict(required=False, allow_none=True) + + @classmethod + def example(cls) -> dict: + """Example response for mailbox batch action. + + :return: Example mailbox batch action response + :rtype: dict + """ + return { + "error_code": 0, + "error_msg": "", + "data": { + "action": "tag", + "results": { + "INBOX": {"action": "tag", "mail_uid": ["42", "43", "27", "21"], "tags_added": ["important"]}, + "Trash": {"action": "tag", "mail_uid": ["42", "43"], "tags_added": ["important"]} + }, + "errors": {} + } + } + + class MailboxPurgeResponseSchema(ApiBaseResponse): """ Schema for POST /mailboxes//purge response 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/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/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 e7c00bd3..e61e3ae8 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 # 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]]: + """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/contact/InterfaceApiContactContact.py b/app/interface/contact/InterfaceApiContactContact.py index 7740275c..d926545f 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 # diff --git a/app/interface/mail/InterfaceApiMailMailbox.py b/app/interface/mail/InterfaceApiMailMailbox.py index 24934126..9273b75c 100644 --- a/app/interface/mail/InterfaceApiMailMailbox.py +++ b/app/interface/mail/InterfaceApiMailMailbox.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING, Any from http import HTTPStatus +from marshmallow import ValidationError + from app.config.settings.DomainSettings import UserModuleSettings, UserModuleSettingsObj, MailSettings, MailSettingsObj from app.module.mail.ModuleMail import ModuleMail from app.module.mail.ModuleMailOutgoing import ModuleMailOutgoing @@ -243,6 +245,27 @@ def purge_mailbox(self, account_id: str, purge_data: dict[str, Any]) -> tuple[di return create_api_base_response(None, ex.error) + def mailbox_batch_action(self, account_id: str, batch_action_data: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Perform an action on multiple mails spanning multiple folders of the same account. + + :param account_id: The account identifier + :type account_id: str + :param batch_action_data: Dictionary containing 'uids' (folder name -> list of uids), + 'action' and optional 'data' fields + :type batch_action_data: dict[str, Any] + :return: A tuple of (API response dict, status code) + :rtype: tuple[dict[str, Any], int] + """ + try: + result = self.mail_module.perform_mailbox_batch_action(account_id, batch_action_data) + return create_api_base_response(result) + except ValidationError as ex: + logger_api.error("Validation error in mailbox_batch_action: %s", ex.messages) + return create_api_base_response(None, err.ERROR_VALIDATION_ERROR) + except RequestException as ex: + logger_api.error("Request exception in mailbox_batch_action for user %s, account %s: %s", self.user.uid, account_id, str(ex)) + return create_api_base_response(None, ex.error) + def save_draft(self, account_id: str, mail_data: dict, key: str | None = None) -> tuple[dict, int]: """Save a mail as a draft in the account's Drafts folder. 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/manager/mail/ClientImap.py b/app/manager/mail/ClientImap.py index a3a4a3fb..c6396fd6 100644 --- a/app/manager/mail/ClientImap.py +++ b/app/manager/mail/ClientImap.py @@ -1648,7 +1648,9 @@ def copy_mail_to_mailbox(self, folder_path: str, mail_uid: str|list[str], dest_f :param type: bool, default to False :raises RequestException: If the operation fails. """ - logger_imap.debug("Copying mail UID '%s' from '%s' to '%s'", mail_uid, folder_path, dest_folder_path) + print("HAAAAAA") + print("Copying mail UID '%s' from '%s' to '%s'", mail_uid, folder_path, dest_folder_path) + logger_imap.info("Copying mail UID '%s' from '%s' to '%s'", mail_uid, folder_path, dest_folder_path) if self.connection is not None and self.authenticated: if not folder_path.isascii() or not dest_folder_path.isascii(): raise RequestException(f"Mailbox name is not ascii: {folder_path} and/or {dest_folder_path}", err.ERROR_IMAP_NOT_ASCII) 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/serializer/CalCalendarSerializerDict.py b/app/module/calendar/serializer/CalCalendarSerializerDict.py index ab7cff0b..2595c801 100644 --- a/app/module/calendar/serializer/CalCalendarSerializerDict.py +++ b/app/module/calendar/serializer/CalCalendarSerializerDict.py @@ -28,4 +28,5 @@ def serialize(self, data: CalCalendar) -> dict[str, Any]: "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, + "owner": data.user_uid, } 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/contact/ModuleContact.py b/app/module/contact/ModuleContact.py index de4c4db1..de4e33b7 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()}", @@ -141,10 +144,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..ba147174 100644 --- a/app/module/contact/source/ContactSources.py +++ b/app/module/contact/source/ContactSources.py @@ -15,6 +15,7 @@ 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 +36,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. @@ -80,12 +82,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/module/mail/ModuleMail.py b/app/module/mail/ModuleMail.py index a5d9ee16..a1f64b04 100644 --- a/app/module/mail/ModuleMail.py +++ b/app/module/mail/ModuleMail.py @@ -22,7 +22,7 @@ from app.utils.maths.crypto_utils import decrypt_password from app.utils.module.importManager import import_and_instantiate_manager from app.utils.logger.logger import logger_mail_server -from app.utils.strings import get_imap_config_from_url, get_domain_from_mail, get_domain_from_contact +from app.utils.strings import get_imap_config_from_url, get_domain_from_mail, get_domain_from_contact, encode_imap_tag, decode_imap_tag from app.utils.constants import DELETE_MAIL_BEHAVIOR_MAP if TYPE_CHECKING: @@ -654,7 +654,7 @@ def _parse_mail(self, mail_dict:dict) -> dict: "answered": flags_dict.get('answered', False), "forwarded": flags_dict.get('forwarded', False), "deleted": flags_dict.get('deleted', False), - "flags": flags_dict.get('all', []), + "flags": [decode_imap_tag(flag) for flag in flags_dict.get('all', [])], "to": to, "from": from_, "cc": cc, @@ -1488,6 +1488,10 @@ def perform_mail_action(self, account_id:str, folder_name: str, mail_uid: str, a return self._action_copy(client, folder_name, mail_uid, data) elif action == "delete": return self._action_delete(client, folder_name, mail_uid, account_id=account_id) + elif action == "illegal": + return self._action_illegal(client, folder_name, mail_uid) + elif action == "phishing": + return self._action_phishing(client, folder_name, mail_uid) else: raise RequestException(f"Invalid action: {action}", err.ERROR_INVALID_ACTION) @@ -1525,9 +1529,44 @@ def perform_mail_batch_action(self, account_id: str, folder_name: str, batch_act return self._action_copy(client, folder_name, mail_uids, data) elif action == "delete": return self._action_delete(client, folder_name, mail_uids, account_id=account_id) + elif action == "illegal": + return self._action_illegal(client, folder_name, mail_uids) + elif action == "phishing": + return self._action_phishing(client, folder_name, mail_uids) else: raise RequestException(f"Invalid action: {action}", err.ERROR_INVALID_ACTION) + def perform_mailbox_batch_action(self, account_id: str, batch_action_data: dict) -> dict[str, Any]: + """Perform an action on multiple mails spanning multiple folders of the same account. + + Loops over ``perform_mail_batch_action`` for each folder listed in ``uids``. A failure on + one folder is recorded in ``errors`` but does not prevent the remaining folders from being + processed. + + :param account_id: The account identifier + :type account_id: str + :param batch_action_data: dictionary containing 'uids' (folder name -> list of uids), + 'action' and optional 'data' fields + :type batch_action_data: dict[str, Any] + :return: Dict with the action, the per-folder results, and the per-folder errors + :rtype: dict[str, Any] + """ + action: str = batch_action_data["action"] + data = batch_action_data.get("data") + uids_by_folder: dict = batch_action_data["uids"] + + results: dict[str, Any] = {} + errors: dict[str, str] = {} + + for folder_name, uids in uids_by_folder.items(): + try: + results[folder_name] = self.perform_mail_batch_action(account_id, folder_name, {"uids": uids, "action": action, "data": data}) + except RequestException as ex: + logger_mail_server.warning("perform_mailbox_batch_action: action '%s' failed for folder '%s': %s", action, folder_name, str(ex)) + errors[folder_name] = ex.error.c + + return {"action": action, "results": results, "errors": errors} + def download_attachment(self, account_id: str, folder_name: str, mail_uid: str, filename: str) -> tuple[bytes, str]: """Download a specific attachment from a mail. @@ -1592,7 +1631,10 @@ def _action_tag(self, client: ClientMailServer, folder_name: str, mail_uid: str| else: raise RequestException("Tags must be a string or list of strings", err.ERROR_MISSING_ACTION_DATA) - client.add_flags_to_mail(folder_name, mail_uid, tag_list) + # IMAP flags are atoms and cannot contain spaces/special chars; encode (reversibly) before sending + encoded_tags = [encode_imap_tag(tag) for tag in tag_list] + + client.add_flags_to_mail(folder_name, mail_uid, encoded_tags) return {"action": "tag", "mail_uid": mail_uid, "tags_added": tag_list} @@ -1620,7 +1662,10 @@ def _action_untag(self, client: ClientMailServer, folder_name: str, mail_uid: st else: raise RequestException("Tags must be a string or list of strings", err.ERROR_MISSING_ACTION_DATA) - client.remove_flags_to_mail(folder_name, mail_uid, tag_list) + # IMAP flags are atoms and cannot contain spaces/special chars; encode (reversibly) before sending + encoded_tags = [encode_imap_tag(tag) for tag in tag_list] + + client.remove_flags_to_mail(folder_name, mail_uid, encoded_tags) return {"action": "untag", "mail_uid": mail_uid, "tags_removed": tag_list} @@ -1679,6 +1724,40 @@ def _action_ham(self, client: ClientMailServer, folder_name: str, mail_uid: str| return {"action": "ham", "mail_uid": mail_uid, "moved_to": inbox_folder} + def _action_illegal(self, client: ClientMailServer, folder_name: str, mail_uid: str|list[str]) -> dict[str, Any]: + """Report a mail or a list of mails as illegal content, copy them to the Junk folder + and permanently remove them (no Trash copy) from their source folder. + + :param folder_name: The name of the folder + :type folder_name: str + :param mail_uid: The unique identifier of the mail, or a list of them + :type mail_uid: str|list[str] + :return: Result with illegal action info + :rtype: dict[str, Any] + :raises RequestException: If operation fails + """ + junk_folder = self.domain_mail_folder_name.get(cs.MAIL_FOLDER_JUNK, "Junk") + client.copy_mail_to_mailbox(folder_name, mail_uid, junk_folder, create_dest=True) + client.delete_mails_by_uid(folder_name, mail_uid, move_to_trash=False, permanently=True) + return {"action": "illegal", "mail_uid": mail_uid, "moved_to": junk_folder} + + def _action_phishing(self, client: ClientMailServer, folder_name: str, mail_uid: str|list[str]) -> dict[str, Any]: + """Report a mail or a list of mails as phishing, copy them to the Junk folder + and permanently remove them (no Trash copy) from their source folder. + + :param folder_name: The name of the folder + :type folder_name: str + :param mail_uid: The unique identifier of the mail, or a list of them + :type mail_uid: str|list[str] + :return: Result with phishing action info + :rtype: dict[str, Any] + :raises RequestException: If operation fails + """ + junk_folder = self.domain_mail_folder_name.get(cs.MAIL_FOLDER_JUNK, "Junk") + client.copy_mail_to_mailbox(folder_name, mail_uid, junk_folder, create_dest=True) + client.delete_mails_by_uid(folder_name, mail_uid, move_to_trash=False, permanently=True) + return {"action": "phishing", "mail_uid": mail_uid, "moved_to": junk_folder} + def _action_copy(self, client: ClientMailServer, folder_name: str, mail_uid: str|list[str], destination: Any) -> dict[str, Any]: """Copy a mail or a list of mails to another folder. diff --git a/app/module/user/ModuleUserProfile.py b/app/module/user/ModuleUserProfile.py index 96446a8e..7cea50e0 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 @@ -667,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/utils/constants.py b/app/utils/constants.py index 31f24c37..864d836e 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 5ccdaabf..8b413c1e 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) #Quota ERROR_IMAP_QUOTA_NOT_SUPPORTED = E("S000336", "IMAP server does not support QUOTA extension", HTTPStatus.NOT_IMPLEMENTED) @@ -223,6 +225,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) @@ -245,6 +248,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) @@ -264,5 +268,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) diff --git a/app/utils/strings.py b/app/utils/strings.py index d9423695..cc28e4c3 100644 --- a/app/utils/strings.py +++ b/app/utils/strings.py @@ -1,3 +1,4 @@ +import base64 import re import unicodedata @@ -188,6 +189,62 @@ def imap_join_folders(delimiter: str, first_path: str, second_path: str) -> str: second_path = second_path[1:-1] return quote(f"{first_path}{delimiter}{second_path}") +# Prefix marking a tag as base32-encoded. Kept short and IMAP-atom-safe (letters/digits only) +# so it never collides with a plain user tag that happens to look like base32. +_IMAP_TAG_ENCODED_PREFIX = "B32-" + + +def encode_imap_tag(tag: str) -> str: + """Encode a user-provided tag into a value that is safe to use as an IMAP flag/keyword. + + Per RFC 3501, a flag is an "atom" and cannot contain spaces, control characters or any of + the special chars ( ) { % * " \\ ] plus SP and CTL. IMAP servers (Dovecot included) will + otherwise silently split on whitespace, turning a single tag like "test avec espace" into + three distinct flags ("test", "avec", "espace"). + + To keep the round-trip lossless (spaces, accents, underscores, punctuation...), the tag is + base32-encoded (padding stripped) and prefixed with a marker. Base32 only produces + ``[A-Z2-7]`` characters, which are always valid IMAP atom characters. + + Tags that are already plain IMAP-safe atoms (letters/digits/._- only, no spaces) are + returned unchanged to keep flags human-readable on the wire when possible. + System flags (starting with '\\', e.g. \\Seen, \\Deleted) are always returned unchanged. + + :param tag: The raw tag value to encode. + :type tag: str + :return: A value safe to use as a single IMAP flag. + :rtype: str + """ + if tag.startswith('\\'): + return tag + if re.fullmatch(r'[A-Za-z0-9._-]+', tag): + return tag + encoded = base64.b32encode(tag.encode('utf-8')).decode('ascii').rstrip('=') + return _IMAP_TAG_ENCODED_PREFIX + encoded + + +def decode_imap_tag(flag: str) -> str: + """Decode an IMAP flag/keyword previously encoded with :func:`encode_imap_tag`. + + Flags that don't carry the encoding prefix (system flags, or plain tags that were kept + as-is because they were already IMAP-safe) are returned unchanged. + + :param flag: The IMAP flag value as received from the server. + :type flag: str + :return: The original, human-readable tag value. + :rtype: str + """ + if not flag.startswith(_IMAP_TAG_ENCODED_PREFIX): + return flag + encoded = flag[len(_IMAP_TAG_ENCODED_PREFIX):] + padding = '=' * (-len(encoded) % 8) + try: + return base64.b32decode(encoded + padding).decode('utf-8') + except (ValueError, UnicodeDecodeError): + # Not actually one of our encoded tags (unlikely collision); return as-is. + return flag + + def string_to_sort_score(s: str) -> int: """Convert a string to an integer score for sorting purposes.""" score = 0 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 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}}