diff --git a/.devcontainer/conf/dovecot/sharing.db b/.devcontainer/conf/dovecot/sharing.db new file mode 100644 index 00000000..ff522139 --- /dev/null +++ b/.devcontainer/conf/dovecot/sharing.db @@ -0,0 +1,2 @@ +shared/shared-boxes/user/sogo-tests2@example.org/sogo-tests1@example.org +1 diff --git a/app/api/v1/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..dd5b8353 100644 --- a/app/api/v1/calendar/schemas/calendar.py +++ b/app/api/v1/calendar/schemas/calendar.py @@ -1,8 +1,9 @@ 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.components import CalendarRightsSchema from app.api.v1.calendar.schemas.event import DateTimeEndUtcField, DateTimeUtcField from app.module.calendar.model.enums.EventVisibility import EventVisibility from app.utils.api.ApiBaseResponse import ApiBaseResponse @@ -69,8 +70,10 @@ class CalendarSchema(Schema): default_type = fields.String(allow_none=True) # Full public subscription URL, computed server-side from the share token when active. public_url = fields.String(allow_none=True, dump_only=True) - # `dump_only`` because permissions are only available when retrieving calendar but can't be set in that way - permissions = fields.Nested(CalendarPermissionsSchema, allow_none=True, dump_only=True) + # `dump_only` because rights are only available when retrieving calendar but can't be set in that way + rights = fields.Nested(CalendarRightsSchema, allow_none=True, dump_only=True) + owner = fields.String(allow_none=True, dump_only=True, + metadata={"description": "UID of the calendar's owner (creator, resolved via sogo6_acl for shared calendars).", "example": "jdoe"}) created_at = fields.DateTime(allow_none=True) updated_at = fields.DateTime(allow_none=True) @@ -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/calendar/schemas/components.py b/app/api/v1/calendar/schemas/components.py index 4d415d2c..b541d7eb 100644 --- a/app/api/v1/calendar/schemas/components.py +++ b/app/api/v1/calendar/schemas/components.py @@ -127,14 +127,14 @@ class DatesWithTzSchema(Schema): date_end_tz_calendar = fields.String(allow_none=True) -class CalendarPermissionsSchema(Schema): - """Resolved ACL permissions for the requesting user (read-only).""" - - public_level = fields.String(metadata={"description": "none | view_datetime | view_all | respond | modify"}) - confidential_level = fields.String(metadata={"description": "none | view_datetime | view_all | respond | modify"}) - private_level = fields.String(metadata={"description": "none | view_datetime | view_all | respond | modify"}) - can_create = fields.Boolean() - can_delete = fields.Boolean() +class CalendarRightsSchema(Schema): + """Resolved ACL rights of the requesting user on a calendar (read-only).""" + + public = fields.String(metadata={"description": "none | view-date-time | view-all | respond-to | modify", "example": "view-all"}) + confidential = fields.String(metadata={"description": "none | view-date-time | view-all | respond-to | modify", "example": "none"}) + private = fields.String(metadata={"description": "none | view-date-time | view-all | respond-to | modify", "example": "none"}) + can_create_objects = fields.Boolean(metadata={"example": True}) + can_erase_objects = fields.Boolean(metadata={"example": False}) class SyncConfigUpdateSchema(Schema): diff --git a/app/api/v1/calendar/schemas/event.py b/app/api/v1/calendar/schemas/event.py index 1273a227..4bf299c4 100644 --- a/app/api/v1/calendar/schemas/event.py +++ b/app/api/v1/calendar/schemas/event.py @@ -8,8 +8,8 @@ from marshmallow import Schema, ValidationError, fields, validate from app.api.v1.calendar.schemas.components import ( - AttachmentCalendarSchema, AttendeeSchema, ConferenceDataSchema, DatesWithTzSchema, EventRelationSchema, - OrganizerSchema, RecurrenceRuleSchema, ReminderSchema, + AttachmentCalendarSchema, AttendeeSchema, CalendarRightsSchema, ConferenceDataSchema, DatesWithTzSchema, + EventRelationSchema, OrganizerSchema, RecurrenceRuleSchema, ReminderSchema, ) from app.module.calendar.CalendarConst import MAX_EVENT_DESCRIPTION_LENGTH, MAX_EVENT_LOCATION_LENGTH, MAX_EVENT_TITLE_LENGTH from app.module.calendar.model.enums.EventStatus import EventStatus @@ -117,6 +117,8 @@ class CalendarEventSchema(Schema): recurrence_id = fields.String(allow_none=True) recurrence_range = fields.String(allow_none=True) dates_with_tz = fields.Nested(DatesWithTzSchema, allow_none=True) + rights = fields.Nested(CalendarRightsSchema, allow_none=True, dump_only=True, + metadata={"description": "Requesting user's rights on the event's calendar (only returned by GET /events/)."}) class CalendarEventCreateSchema(Schema): diff --git a/app/api/v1/contact/ApiContact.py b/app/api/v1/contact/ApiContact.py index 75cca9cb..3b0ce83d 100644 --- a/app/api/v1/contact/ApiContact.py +++ b/app/api/v1/contact/ApiContact.py @@ -7,12 +7,13 @@ from flask.typing import ResponseReturnValue from flask_smorest import Blueprint +from app.config.settings.DomainSettings import UserModuleSettings from app.interface.contact.InterfaceApiContactContact import InterfaceApiContactContact from app.module.contact.ContactConst import IMPORT_MAX_BYTES from app.module.contact.source.ContactSourceDb import LIST_SORTABLE_COLUMNS, SORTABLE_COLUMNS from app.utils.api.ApiBaseResponse import create_api_base_response from app.utils.api.paginate_sort_filter import collection_paginate, CustomPaginateResponse -from app.utils.errors import ERROR_CONTACT_IMPORT_NO_FILE, ERROR_CONTACT_IMPORT_TOO_LARGE +from app.utils.errors import ERROR_CONTACT_IMPORT_NO_FILE, ERROR_CONTACT_IMPORT_TOO_LARGE, ERROR_CONTACT_SHARING_DISABLED from app.utils.logger.logger import logger_api from .schemas.addressbook import ( AddressBookCreateSchema, @@ -22,6 +23,10 @@ ContactImportQueryArgsSchema, ContactImportUploadSchema, ContactJobResponseSchema, + ContactSharePatchSchema, + ContactSharePutSchema, + ContactSharePostSchema, + ContactShareResponseSchema, ) from .schemas.contact import ( ContactCreateSchema, @@ -67,7 +72,14 @@ @blp.before_request -def init_contact_config() -> None: # pylint: disable=missing-function-docstring +def init_contact_config() -> ResponseReturnValue | None: # pylint: disable=missing-function-docstring + if request.path.endswith("/share"): + user_domain_settings: dict = g.user_domain_settings + user_module_settings: dict = user_domain_settings.get(UserModuleSettings.subparent, {}) + if "contact" in user_module_settings.get("SOGO_D_FOLDER_DISABLE_SHARING", []): + logger_api.debug("Access denied for %s: contact sharing is disabled", request.path) + return create_api_base_response(None, ERROR_CONTACT_SHARING_DISABLED) + g.inter = InterfaceApiContactContact( process_setting=g.process_settings, user_domain_settings=g.user_domain_settings, @@ -122,6 +134,49 @@ def delete(self, key: str) -> ResponseReturnValue: return interface.delete_addressbook(key) +@blp.route("/addressbooks//share") +class ApiAddressBookShare(MethodView): + """API to manage address book sharing and user permissions.""" + + @blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example()) + def get(self, key: str) -> ResponseReturnValue: + """Get all user permissions for an address book.""" + logger_api.debug("GET /addressbooks/%s/share user=%s", key, g.user.uid) + interface: InterfaceApiContactContact = g.inter + return interface.get_addressbook_share(key) + + @blp.arguments(ContactSharePatchSchema(many=True), example=ContactSharePatchSchema.example()) # type: ignore [arg-type] + @blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example()) + def patch(self, body: list[dict], key: str) -> ResponseReturnValue: + """Partially update user permissions for an address book. + + Only the users specified in the request body are modified. + Other existing permissions remain unchanged. + """ + logger_api.debug("PATCH /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body) + interface: InterfaceApiContactContact = g.inter + return interface.patch_addressbook_share(key, body) + + @blp.arguments(ContactSharePutSchema(many=True), example=ContactSharePutSchema.example()) # type: ignore [arg-type] + @blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example()) + def put(self, body: list[dict], key: str) -> ResponseReturnValue: + """Replace all user permissions for an address book. + + All existing permissions are replaced by the users specified in the request body. + """ + logger_api.debug("PUT /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body) + interface: InterfaceApiContactContact = g.inter + return interface.put_addressbook_share(key, body) + + @blp.arguments(ContactSharePostSchema(many=True), example=ContactSharePostSchema.example()) # type: ignore [arg-type] + @blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example()) + def post(self, body: list[dict], key: str) -> ResponseReturnValue: + """Grant full permissions to one or several users.""" + logger_api.debug("POST /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body) + interface: InterfaceApiContactContact = g.inter + return interface.post_addressbook_share(key, body) + + @blp.route("/addressbooks//contacts") class ApiAddressBookContactList(MethodView): """API to list (paginated) and create contacts within one address book.""" diff --git a/app/api/v1/contact/schemas/addressbook.py b/app/api/v1/contact/schemas/addressbook.py index d981adba..64435a3b 100644 --- a/app/api/v1/contact/schemas/addressbook.py +++ b/app/api/v1/contact/schemas/addressbook.py @@ -1,6 +1,8 @@ from __future__ import annotations -from marshmallow import Schema, fields, validate +from typing import Any + +from marshmallow import Schema, fields, validate, validates_schema, ValidationError from app.utils.api.ApiBaseResponse import ApiBaseResponse @@ -31,6 +33,8 @@ class AddressBookSchema(Schema): is_default = fields.Boolean() source_type = fields.String() ctag = fields.Integer(metadata={"description": "CardDAV change tag, bumped on every contact mutation."}) + owner = fields.String(allow_none=True, dump_only=True, + metadata={"description": "UID of the address book's owner (creator, resolved via sogo6_acl for shared address books).", "example": "jdoe"}) class AddressBookListDataSchema(Schema): @@ -83,3 +87,164 @@ class ContactImportUploadSchema(Schema): metadata={"type": "string", "format": "binary", "description": "The JSON (.json), vCard (.vcf) or LDIF (.ldif) file to import."}, ) + + +class ContactShareRightsSchema(Schema): + """Permission rights for an address book share.""" + + can_view = fields.Boolean(required=True, metadata={"description": "Can view contacts and lists", "example": True}) + can_create_objects = fields.Boolean(required=True, metadata={"description": "Can create contacts and lists", "example": True}) + can_edit_objects = fields.Boolean(required=True, metadata={"description": "Can edit contacts and lists", "example": True}) + can_erase_objects = fields.Boolean(required=True, metadata={"description": "Can delete contacts and lists", "example": False}) + + +class ContactShareUserSchema(Schema): + """User permission entry in address book sharing. + + ``c_email`` and ``uid`` are required unless ``user_class`` is ``"anyone"``, in which case + they are ignored (the share applies to any authenticated user, not a specific one). + """ + + c_email = fields.String(required=False, allow_none=True, metadata={"description": "User email address", "example": "jdoe@example.org"}) + uid = fields.String(required=False, allow_none=True, metadata={"description": "User UID", "example": "jdoe"}) + user_class = fields.String( + required=True, + validate=validate.OneOf(["user", "anyone"]), + ) + rights = fields.Nested(ContactShareRightsSchema, required=True, metadata={"description": "Permission rights for this user"}) + + @validates_schema + def validate_user_identity(self, data: dict[str, Any], **kwargs: Any) -> None: # pylint: disable=unused-argument + """Require c_email and uid unless user_class is 'anyone'.""" + if data.get("user_class") == "anyone": + return + errors: dict[str, list[str]] = {} + if not data.get("c_email"): + errors["c_email"] = ["Missing data for required field."] + if not data.get("uid"): + errors["uid"] = ["Missing data for required field."] + if errors: + raise ValidationError(errors) + + +class ContactSharePatchSchema(ContactShareUserSchema): + """Request body item for PATCH /addressbooks/{key}/share - partial update of user permissions. + + The endpoint expects a JSON list of these objects (use with ``many=True``). + Only the users specified in the request are modified. Other existing permissions remain unchanged. + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": False + } + } + ] + + +class ContactSharePutSchema(ContactShareUserSchema): + """Request body item for PUT /addressbooks/{key}/share - replace all user permissions. + + The endpoint expects a JSON list of these objects (use with ``many=True``). + All existing permissions are replaced by the users specified in the request. + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": False + } + }, + { + "c_email": "alice@example.org", + "uid": "alice", + "user_class": "user", + "rights": { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": True + } + } + ] + + +class ContactSharePostSchema(ContactShareUserSchema): + """Request body item for POST /addressbooks/{key}/share - grant full permissions to users. + + The endpoint expects a JSON list of these objects (use with ``many=True``). + Grants full view/create/edit/erase rights to the specified users, regardless of the rights + carried in the request body. + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": True + } + } + ] + + +class ContactShareResponseSchema(ApiBaseResponse): + """Response schema for address book sharing endpoints. ``data`` is a plain list of users.""" + + data = fields.List(fields.Nested(ContactShareUserSchema), allow_none=True) + + @staticmethod + def example() -> dict[str, Any]: + """Example full envelope for Swagger documentation.""" + return { + "data": [ + { + "c_email": "jdoe@example.org", + "uid": "jdoe", + "user_class": "user", + "rights": { + "can_view": True, + "can_create_objects": True, + "can_edit_objects": True, + "can_erase_objects": False + } + } + ], + "error_code": "S000000", + "error_msg": "No Error" + } diff --git a/app/api/v1/contact/schemas/contact.py b/app/api/v1/contact/schemas/contact.py index ba0ee74a..25c4bf47 100644 --- a/app/api/v1/contact/schemas/contact.py +++ b/app/api/v1/contact/schemas/contact.py @@ -10,6 +10,7 @@ ContactPhoneSchema, ContactUrlSchema, ) +from app.api.v1.contact.schemas.addressbook import ContactShareRightsSchema from app.module.contact.model.enums.CardKind import CardKind from app.utils.api.ApiBaseResponse import ApiBaseResponse @@ -134,6 +135,8 @@ class ContactListDataSchema(Schema): """Data payload for the contact list response. The total count is in the X-Pagination header.""" contacts = fields.List(fields.Nested(ContactSchema)) + rights = fields.Nested(ContactShareRightsSchema, metadata={ + "description": "Current user's permissions on the address book. Only present when listing a single address book."}) class ContactListResponseSchema(ApiBaseResponse): diff --git a/app/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/ApiMailFolder.py b/app/api/v1/mail/ApiMailFolder.py index dcba66d0..5cbc716d 100644 --- a/app/api/v1/mail/ApiMailFolder.py +++ b/app/api/v1/mail/ApiMailFolder.py @@ -13,7 +13,9 @@ FolderCreateSchema, FolderUpdateSchema, FolderPurgeSchema, - FolderShareSchema, + FolderSharePatchSchema, + FolderSharePutSchema, + FolderSharePostSchema, FolderListResponseSchema, FolderCreateResponseSchema, FolderDetailsResponseSchema, @@ -211,14 +213,46 @@ def post(self, account_id: str, folder_name: str) -> ResponseReturnValue: @blp.route("//share") class ApiMailFolderIdShare(MethodView): - """API to share a specific mail folder. + """API to manage sharing of a specific mail folder and its users' permissions. + + Rights can be expressed two ways in the request body, and at least one of them must be + present for each user entry (an empty value is allowed and means "grant no rights"): + + - ``permissions``: a simplified list of IMAP ACL codes to grant, e.g. ``["l", "r"]``. + Any code not listed is considered not granted. + - ``rights``: an advanced object with one explicit 0/1 flag per right, e.g. + ``{"user_can_view_folder": 1, "user_can_read_mails": 1}``. + + Correspondence between simplified codes and advanced rights (see + ``FOLDER_PERMISSION_CODE_TO_RIGHT`` in ``app/api/v1/mail/schemas/folder.py``): + + | Code IMAP | Droit avancé | + |---|---| + | l | user_can_view_folder (Voir le dossier) | + | r | user_can_read_mails (Lire les mails) | + | s | user_can_mark_mails_read (Marquer comme lu/non lu) | + | w | user_can_write_mails (Modifier les indicateurs des mails) | + | i | user_can_insert_mails (Insérer, copier des mails) | + | p | user_can_post_mails (Envoyer des mails) | + | k | user_can_create_subfolders (Créer des sous-dossiers) | + | x | user_can_remove_folder (Supprimer le dossier) | + | t | user_can_erase_mails (Effacer les mails) | + | e | user_can_expunge_folder (Purger le dossier) | + | a | user_is_administrator (Administrer les droits du dossier) | + + If both ``permissions`` and ``rights`` are provided for the same entry, they must agree on + every right they both cover. Otherwise the API answers ``400 S001103`` + (``ERROR_SHARE_PERMISSIONS_RIGHTS_MISMATCH``). + + Each entry also requires ``user_class`` (``"user"`` or ``"anyone"``); ``c_email`` and ``uid`` + are required when ``user_class`` is ``"user"``. """ @blp.response(200, FolderShareResponseSchema, example=FolderShareResponseSchema.example()) def get(self, account_id: str, folder_name: str) -> ResponseReturnValue: #TODO: pagination? """Get share information for the specified folder. - + Returns the list of users who have access to this folder and their permissions. - + :param account_id: The ID of the account :type account_id: str :param folder_name: The ID of the folder @@ -230,14 +264,58 @@ def get(self, account_id: str, folder_name: str) -> ResponseReturnValue: #TOD interface: InterfaceApiMailFolder = g.inter return interface.get_folder_share(account_id, folder_name) - @blp.arguments(FolderShareSchema(many=True), example=FolderShareSchema.example(), error_status_code=400) #type: ignore [arg-type] + @blp.arguments(FolderSharePatchSchema(many=True), example=FolderSharePatchSchema.example(), error_status_code=400) # type: ignore [arg-type] + @blp.response(200, FolderShareResponseSchema, example=FolderShareResponseSchema.example()) + def patch(self, share_data: list, account_id: str, folder_name: str) -> ResponseReturnValue: + """Partially update sharing rights for the specified folder. + + Only the users specified in the request body are modified. Other existing shares + remain unchanged. See the resource docstring for the ``permissions``/``rights`` format. + + :param share_data: List of users with their rights configuration + :type share_data: list + :param account_id: The ID of the account + :type account_id: str + :param folder_name: The ID of the folder + :type folder_name: str + :return: ApiBaseResponse with share result + :rtype: ResponseReturnValue + """ + logger_api.debug("Calling ApiMailFolderIdShare.patch for account_id: %s, folder_name: %s with data: %s", + account_id, folder_name, share_data) + interface: InterfaceApiMailFolder = g.inter + return interface.patch_folder_share(account_id, folder_name, share_data) + + @blp.arguments(FolderSharePutSchema(many=True), example=FolderSharePutSchema.example(), error_status_code=400) # type: ignore [arg-type] + @blp.response(200, FolderShareResponseSchema, example=FolderShareResponseSchema.example()) + def put(self, share_data: list, account_id: str, folder_name: str) -> ResponseReturnValue: + """Replace all sharing rights for the specified folder. + + All existing shares are replaced by the users specified in the request body. + See the resource docstring for the ``permissions``/``rights`` format. + + :param share_data: List of users with their rights configuration + :type share_data: list + :param account_id: The ID of the account + :type account_id: str + :param folder_name: The ID of the folder + :type folder_name: str + :return: ApiBaseResponse with share result + :rtype: ResponseReturnValue + """ + logger_api.debug("Calling ApiMailFolderIdShare.put for account_id: %s, folder_name: %s with data: %s", + account_id, folder_name, share_data) + interface: InterfaceApiMailFolder = g.inter + return interface.put_folder_share(account_id, folder_name, share_data) + + @blp.arguments(FolderSharePostSchema(many=True), example=FolderSharePostSchema.example(), error_status_code=400) # type: ignore [arg-type] @blp.response(200, FolderShareResponseSchema, example=FolderShareResponseSchema.example()) def post(self, share_data: list, account_id: str, folder_name: str) -> ResponseReturnValue: - """Action: Share the specified folder with another user. - - Sets ACL permissions on the folder for the specified users. - The request body should be a list of user objects with their rights. - + """Grant sharing rights on the specified folder to one or several users. + + Adds or updates ACL permissions on the folder for the specified users, in addition to + any existing share. See the resource docstring for the ``permissions``/``rights`` format. + :param share_data: List of users with their rights configuration :type share_data: list :param account_id: The ID of the account @@ -250,4 +328,4 @@ def post(self, share_data: list, account_id: str, folder_name: str) -> ResponseR logger_api.debug("Calling ApiMailFolderIdShare.post for account_id: %s, folder_name: %s with data: %s", account_id, folder_name, share_data) interface: InterfaceApiMailFolder = g.inter - return interface.share_folder(account_id, folder_name, share_data) + return interface.post_folder_share(account_id, folder_name, share_data) diff --git a/app/api/v1/mail/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/folder.py b/app/api/v1/mail/schemas/folder.py index 652e5bb8..5675873d 100644 --- a/app/api/v1/mail/schemas/folder.py +++ b/app/api/v1/mail/schemas/folder.py @@ -1,5 +1,10 @@ -from marshmallow import Schema, fields +from typing import Any + +from marshmallow import Schema, fields, validate, validates_schema, ValidationError from app.utils.api.ApiBaseResponse import ApiBaseResponse +# The correspondence table lives in app.factory.share.shareMailFolder - shared with ModuleMail's +# IMAP ACL calls (see ClientImap.set_acl_raw/get_acl_raw) - and re-imported here for schema use. +from app.factory.share.shareMailFolder import FOLDER_SHARE_PERMISSION_CODES, FOLDER_PERMISSION_CODE_TO_RIGHT class FolderCreateSchema(Schema): @@ -69,75 +74,186 @@ def example(cls) -> dict: } -class FolderShareRightsSchema(Schema): - """ - Schema for folder sharing rights. +class FolderShareRightsInputSchema(Schema): """ - userCanEraseMails = fields.Integer() - userCanExpungeFolder = fields.Integer() - userCanInsertMails = fields.Integer() - userIsAdministrator = fields.Integer() - userCanWriteMails = fields.Integer() - userCanMarkMailsRead = fields.Integer() - userCanViewFolder = fields.Integer() - userCanCreateSubfolders = fields.Integer() - userCanPostMails = fields.Integer() - userCanReadMails = fields.Integer() - userCanRemoveFolder = fields.Integer() - - -class FolderShareSchema(Schema): + Advanced permission rights (one flag per IMAP ACL code) for the folder sharing request body. + + Every field is a 0/1 flag and optional: only pass the rights you want to state explicitly. + See ``FOLDER_PERMISSION_CODE_TO_RIGHT`` for the IMAP code each field corresponds to. """ - Schema for a user entry in folder sharing. - Use with many=True to validate a list of users. + user_can_view_folder = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Voir le dossier (l)"}) + user_can_read_mails = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Lire les mails (r)"}) + user_can_mark_mails_read = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Marquer comme lu/non lu (s)"}) + user_can_write_mails = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Modifier les indicateurs des mails (w)"}) + user_can_insert_mails = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Insérer, copier des mails (i)"}) + user_can_post_mails = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Envoyer des mails (p)"}) + user_can_create_subfolders = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Créer des sous-dossiers (k)"}) + user_can_remove_folder = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Supprimer le dossier (x)"}) + user_can_erase_mails = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Effacer les mails (t)"}) + user_can_expunge_folder = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Purger le dossier (e)"}) + user_is_administrator = fields.Integer(validate=validate.OneOf([0, 1]), metadata={"description": "Administrer les droits du dossier (a)"}) + + +class FolderShareEntrySchema(Schema): """ - is_group = fields.Integer() - c_email = fields.String(required=True) - cn = fields.String() - uid = fields.String(required=True) - user_class = fields.String() - rights = fields.Nested(FolderShareRightsSchema, ) + Base schema for a user (or "anyone") entry in a mail folder sharing request. - @classmethod - def example(cls) -> list: - """ - Example data for folder sharing. + Rights can be expressed two ways, and at least one of them must be present in the request + (an empty value is allowed and means "grant no rights", e.g. to revoke a user down to + no access while keeping the share entry): + + - ``permissions``: a simplified list of IMAP ACL codes to grant (``l r s w i p k x t e a``). + Any code not listed is considered not granted. + - ``rights``: an advanced object with one explicit 0/1 flag per right + (see :class:`FolderShareRightsInputSchema`). + + If both ``permissions`` and ``rights`` are provided, they must agree: each code in + ``permissions`` must match its corresponding ``rights`` flag (see + ``FOLDER_PERMISSION_CODE_TO_RIGHT`` for the code <-> flag mapping). Otherwise the API + answers ``400 S001103`` (see ``ERROR_SHARE_PERMISSIONS_RIGHTS_MISMATCH`` in + ``app/utils/errors.py``). - :return: Example folder share payload (a list). - :rtype: list + ``c_email`` and ``uid`` are required unless ``user_class`` is ``"anyone"``, in which case + they are ignored (the share applies to any authenticated user, not a specific one). + """ + c_email = fields.String(required=False, allow_none=True, metadata={"description": "User email address", "example": "a@a.fr"}) + uid = fields.String(required=False, allow_none=True, metadata={"description": "User UID", "example": "a@a.fr"}) + user_class = fields.String( + required=True, + validate=validate.OneOf(["user", "anyone"]), + metadata={"description": "'user' for a specific user (needs c_email/uid), 'anyone' for every authenticated user"} + ) + permissions = fields.List( + fields.String(validate=validate.OneOf(FOLDER_SHARE_PERMISSION_CODES)), + required=False, + metadata={"description": "Simplified list of IMAP ACL codes to grant: l, r, s, w, i, p, k, x, t, e, a"} + ) + rights = fields.Nested( + FolderShareRightsInputSchema, + required=False, + metadata={"description": "Advanced per-right 0/1 flags, cross-checked against 'permissions' if both are given"} + ) + do_subfolders = fields.Boolean( + load_default=False, dump_default=False, + metadata={"description": "Also apply these rights to all subfolders"} + ) + + @validates_schema + def validate_user_identity(self, data: dict[str, Any], **kwargs: Any) -> None: # pylint: disable=unused-argument + """Require c_email and uid unless user_class is 'anyone'.""" + if data.get("user_class") == "anyone": + return + errors: dict[str, list[str]] = {} + if not data.get("c_email"): + errors["c_email"] = ["Missing data for required field."] + if not data.get("uid"): + errors["uid"] = ["Missing data for required field."] + if errors: + raise ValidationError(errors) + + @validates_schema + def validate_permissions_or_rights(self, data: dict[str, Any], **kwargs: Any) -> None: # pylint: disable=unused-argument + """Require at least one of 'permissions' or 'rights' to be present. + + An empty value (``"permissions": []`` or ``"rights": {}``) is accepted: it explicitly + grants no rights, which is different from omitting both keys entirely. """ + if "permissions" not in data and "rights" not in data: + raise ValidationError( + "At least one of 'permissions' or 'rights' must be provided.", + field_name="_schema" + ) + + +class FolderSharePatchSchema(FolderShareEntrySchema): + """Request body item for PATCH /mailboxes/{account_id}/folders/{folder_name}/share. + + Partially updates the sharing rights of the specified users: only the users listed in the + request body are modified, other existing shares are left untouched. + The endpoint expects a JSON list of these objects (use with ``many=True``). + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" return [ { - "c_email": "tkeriven@snapshot.alinto.org", - "cn": "tkeriven", - "uid": "tkeriven@snapshot.alinto.org", + "uid": "a@a.fr", + "c_email": "a@a.fr", "user_class": "user", - "rights": { - "userCanInsertMails": 1, - "userCanMarkMailsRead": 1, - "userCanPostMails": 1, - "userCanReadMails": 1, - "userCanRemoveFolder": 1, - "userCanViewFolder": 1, - "userCanWriteMails": 1, - "userIsAdministrator": 1 - } + "permissions": ["l", "r"], + "do_subfolders": False + } + ] + + +class FolderSharePutSchema(FolderShareEntrySchema): + """Request body item for PUT /mailboxes/{account_id}/folders/{folder_name}/share. + + Replaces all sharing rights on the folder: existing shares are entirely replaced by the + users listed in the request body. + The endpoint expects a JSON list of these objects (use with ``many=True``). + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "user_class": "anyone", + "permissions": ["l", "r", "s", "w", "i", "p", "t", "e", "a"], + "do_subfolders": True }, { - "c_email": "jnadal@snapshot.alinto.org", - "cn": "jnadal", - "uid": "jnadal@snapshot.alinto.org", + "c_email": "a@a.fr", + "uid": "a@a.fr", + "user_class": "user", + "permissions": ["l", "r"], + "do_subfolders": False + } + ] + + +class FolderSharePostSchema(FolderShareEntrySchema): + """Request body item for POST /mailboxes/{account_id}/folders/{folder_name}/share. + + Grants (or creates) sharing rights for the specified users, in addition to any existing share. + The endpoint expects a JSON list of these objects (use with ``many=True``). + """ + + class Meta: + ordered = True + + @staticmethod + def example() -> list[dict[str, Any]]: + """Example data for Swagger documentation.""" + return [ + { + "c_email": "sogo-tests1@example.org", + "uid": "sogo-tests1@example.org", "user_class": "user", "rights": { - "userCanInsertMails": 1, - "userCanMarkMailsRead": 1, - "userCanPostMails": 1, - "userCanReadMails": 1, - "userCanRemoveFolder": 1, - "userCanViewFolder": 1, - "userCanWriteMails": 1, - "userIsAdministrator": 1 - } + "user_can_insert_mails": 1, + "user_can_mark_mails_read": 1, + "user_can_post_mails": 1, + "user_can_read_mails": 1, + "user_can_remove_folder": 1, + "user_can_view_folder": 1, + "user_can_write_mails": 1, + "user_is_administrator": 1 + }, + "permissions": ["l", "r", "s", "w", "i", "p", "x", "a"] + }, + { + "user_class": "anyone", + "permissions": ["l", "r"], + "do_subfolders": False } ] @@ -343,48 +459,44 @@ class FolderShareResponseSchema(ApiBaseResponse): """ Schema for GET/POST /mailboxes//folders//share response """ - data = fields.Dict(required=False, allow_none=True) + data = fields.List(fields.Dict(), required=False, allow_none=True) @classmethod def example(cls) -> dict: """Example response for folder share. - + :return: Example folder share response :rtype: dict """ return { "error_code": 0, "error_msg": "", - "data": { - "users": { - "tkeriven@snapshot.alinto.org": { - "user_class": "user", - "c_email": "tkeriven@snapshot.alinto.org", - "cn": "tkeriven", - "uid": "tkeriven@snapshot.alinto.org", - "rights": { - "userCanEraseMails": 1, - "userCanExpungeFolder": 1, - "userCanInsertMails": 1, - "userIsAdministrator": 1, - "userCanWriteMails": 1, - "userCanMarkMailsRead": 1, - "userCanViewFolder": 1, - "userCanCreateSubfolders": 1, - "userCanPostMails": 1, - "userCanReadMails": 1, - "userCanRemoveFolder": 1 - } - }, - "anyone": { - "user_class": "anyone", - "cn": "Tout utilisateur identifié", - "uid": "anyone", - "rights": { - "userCanViewFolder": 1, - "userCanReadMails": 1 - } + "data": [ + { + "user_class": "user", + "c_email": "sogo-tests1@example.org", + "uid": "sogo-tests1@example.org", + "rights": { + "userCanEraseMails": 1, + "userCanExpungeFolder": 1, + "userCanInsertMails": 1, + "userIsAdministrator": 1, + "userCanWriteMails": 1, + "userCanMarkMailsRead": 1, + "userCanViewFolder": 1, + "userCanCreateSubfolders": 1, + "userCanPostMails": 1, + "userCanReadMails": 1, + "userCanRemoveFolder": 1 + } + }, + { + "user_class": "anyone", + "uid": "anyone", + "rights": { + "userCanViewFolder": 1, + "userCanReadMails": 1 } } - } + ] } diff --git a/app/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..0f372f92 --- /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/factory/share/shareMailFolder.py b/app/factory/share/shareMailFolder.py new file mode 100644 index 00000000..3a4f1838 --- /dev/null +++ b/app/factory/share/shareMailFolder.py @@ -0,0 +1,65 @@ +from __future__ import annotations + + +from app.factory.share.share import Share + +# Discriminant stored in sogo6_acl.type for mail folder shares. +FOLDER_RESOURCE_TYPE: str = "folder" + +# IMAP ACL codes exposed through the simplified `permissions` list (see FolderShareEntrySchema). +# Each code maps 1:1 to a boolean flag of the advanced `rights` object. +FOLDER_SHARE_PERMISSION_CODES: list[str] = ["l", "r", "s", "w", "i", "p", "k", "x", "t", "e", "a"] + +# code IMAP -> SOGo folder right name. The single correspondence table between the API's +# simplified `permissions` codes, the advanced `rights` flags, and the raw IMAP ACL characters +# sent to the mail server (see ClientImap.set_acl_raw/get_acl_raw). Lives here (not in the API +# schemas) so both the API layer and ModuleMail can share it without violating layering. +FOLDER_PERMISSION_CODE_TO_RIGHT: dict[str, str] = { + "l": "user_can_view_folder", # Voir le dossier + "r": "user_can_read_mails", # Lire les mails + "s": "user_can_mark_mails_read", # Marquer comme lu/non lu + "w": "user_can_write_mails", # Modifier les indicateurs des mails + "i": "user_can_insert_mails", # Insérer, copier des mails + "p": "user_can_post_mails", # Envoyer des mails + "k": "user_can_create_subfolders", # Créer des sous-dossiers + "x": "user_can_remove_folder", # Supprimer le dossier + "t": "user_can_erase_mails", # Effacer les mails + "e": "user_can_expunge_folder", # Purger le dossier + "a": "user_is_administrator", # Administrer les droits du dossier +} + + +def rights_to_imap_permissions(rights: dict[str, int]) -> str: + """Convert a resolved folder rights dict into the ordered raw IMAP ACL rights string. + + :param rights: full folder rights dict (one 0/1 flag per FOLDER_PERMISSION_CODE_TO_RIGHT entry). + :return: IMAP ACL characters to grant, in FOLDER_PERMISSION_CODE_TO_RIGHT's canonical order. + """ + return "".join(code for code, right in FOLDER_PERMISSION_CODE_TO_RIGHT.items() if rights.get(right)) + + +def imap_permissions_to_rights(imap_rights: str) -> dict[str, int]: + """Convert a raw IMAP ACL rights string into the full folder rights dict (0/1 flags). + + :param imap_rights: raw IMAP ACL characters as returned by GETACL (e.g. "lrswipkxtea"). + :return: full folder rights dict, one 0/1 flag per FOLDER_PERMISSION_CODE_TO_RIGHT entry. + """ + granted = set(imap_rights) + return {right: (1 if code in granted else 0) for code, right in FOLDER_PERMISSION_CODE_TO_RIGHT.items()} + + +class ShareMailFolder(Share): + """Sharing for mail folders, backed by sogo6_acl (type='folder'). + + The rights blob stored per (folder key, to_user) matches the API's + FolderShareRightsInputSchema: one 0/1 flag per IMAP ACL right (see + ``FOLDER_PERMISSION_CODE_TO_RIGHT`` above). + + ``rights_needed`` passed to ``check_permissions`` is the name of the right to check + (e.g. "user_can_read_mails"). + """ + + resource_type: str = FOLDER_RESOURCE_TYPE + + def _rights_satisfy(self, rights: dict, rights_needed: str) -> bool: + return bool(rights.get(rights_needed, False)) diff --git a/app/interface/calendar/InterfaceApiCalendarCalendar.py b/app/interface/calendar/InterfaceApiCalendarCalendar.py index e7c00bd3..1e66bade 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 @@ -33,6 +36,7 @@ from app.module.calendar.serializer.CalTaskDeserializerDict import CalTaskDeserializerDict from app.module.calendar.serializer.CalTaskSerializerDict import CalTaskSerializerDict from app.module.calendar.serializer.CalCalendarSerializerDict import CalCalendarSerializerDict +from app.module.calendar.serializer.CalendarPermissionsSerializerDict import CalendarPermissionsSerializerDict from app.module.calendar.serializer.CalCalendarsSerializerList import CalCalendarsSerializerList from app.module.calendar.serializer.CalEventReminderSerializerDict import CalEventReminderSerializerDict from app.module.calendar.serializer.CalFreeBusyResultSerializerDict import CalFreeBusyResultSerializerDict @@ -65,6 +69,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. @@ -80,6 +85,7 @@ def __init__(self, process_setting: ProcessSetting, user_domain_settings: dict, self._calendar_deserializer: CalCalendarDeserializerDict = CalCalendarDeserializerDict() self._calendar_serializer: CalCalendarSerializerDict = CalCalendarSerializerDict() self._calendars_serializer: CalCalendarsSerializerList = CalCalendarsSerializerList() + self._permissions_serializer: CalendarPermissionsSerializerDict = CalendarPermissionsSerializerDict() self._freebusy_serializer: CalFreeBusyResultSerializerDict = CalFreeBusyResultSerializerDict() self._reminder_serializer: CalEventReminderSerializerDict = CalEventReminderSerializerDict() self._sync_status_serializer: CalSyncStatusSerializerDict = CalSyncStatusSerializerDict() @@ -90,7 +96,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 +206,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) @@ -250,8 +258,13 @@ def _send_imip(self, imip_msg: ImipMessage) -> None: def get_event(self, event_key: str) -> tuple[dict[str, Any], int]: """Get a single event by key.""" try: - event: CalEvent = self.module.get_event(self._event_user_for(event_key), event_key) - return create_api_base_response(self._event_serializer.serialize(event)) + calendar_user: CalendarUser = self._event_user_for(event_key) + event: CalEvent = self.module.get_event(calendar_user, event_key) + event_dict: dict[str, Any] = self._event_serializer.serialize(event) + event_dict["rights"] = self._permissions_serializer.serialize( + self.module.get_event_permissions(calendar_user, event_key), + ) + return create_api_base_response(event_dict) except RequestException as ex: logger_api.error("get_event failed for user %s event %s: %s", self.user.uid, event_key, ex) return create_api_base_response(None, ex.error) @@ -647,3 +660,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..7f89e4c4 100644 --- a/app/interface/contact/InterfaceApiContactContact.py +++ b/app/interface/contact/InterfaceApiContactContact.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterable from typing import TYPE_CHECKING, Any from app.config.settings.DomainSettings import ( @@ -8,6 +9,8 @@ UserModuleSettings, UserModuleSettingsObj, ) +from app.factory.share.RepositoryAcl import AclEntry +from app.module.auth.ModuleUserSource import ModuleUserSource from app.module.contact.ContactConst import AUTOCOMPLETE_DEFAULT_LIMIT from app.module.contact.ModuleContact import ModuleContact from app.module.contact.jobs.ContactJobKind import ContactJobKind @@ -35,6 +38,7 @@ from app.utils.exceptions import RequestException from app.auth.User import User from app.utils.logger.logger import logger_api +from app.utils import constants as cs if TYPE_CHECKING: from app.config.settings.ProcessSetting import ProcessSetting @@ -53,6 +57,7 @@ class InterfaceApiContactContact: # pylint: disable=too-many-instance-attribute def __init__(self, process_setting: ProcessSetting, user_domain_settings: dict, user: User) -> None: self.user: User = user self._process_setting: ProcessSetting = process_setting + self._user_domain_settings: dict = user_domain_settings self.settings: CalendarContactSettingsObj = CalendarContactSettingsObj( user_domain_settings[CalendarContactSettings.subparent] ) @@ -126,12 +131,131 @@ def update_addressbook(self, key: str, body: dict[str, Any]) -> tuple[dict[str, def delete_addressbook(self, key: str) -> tuple[dict[str, Any], int]: """Delete an address book and all its contacts.""" try: - self.module.delete_addressbook(self.user, key) + shared_uids: list[str] = self.module.delete_addressbook(self.user, key) + for shared_uid in shared_uids: + self._user_module.remove_folder_key(shared_uid, "ADDRESSBOOKS", key, owner_key="SUBS") return create_api_base_response(None) except RequestException as ex: logger_api.error("delete_addressbook failed for user %s key %s: %s", self.user.uid, key, ex) return create_api_base_response(None, ex.error) + # + # Address book sharing + # + def get_addressbook_share(self, key: str) -> tuple[dict[str, Any], int]: + """Get all user permissions for an address book. + + :param key: Address book key. + :return: API envelope with list of users and their permission levels. + """ + try: + entries: list[AclEntry] = self.module.get_addressbook_share(self.user, key) + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("get_addressbook_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + def patch_addressbook_share(self, key: str, body: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Partially update user permissions for an address book. + + Only the users specified in the request body are modified. + Other existing permissions remain unchanged. + + :param key: Address book key. + :param body: List of users (uid and rights) to update. + :return: API envelope with updated user permissions. + """ + try: + users: list[dict[str, Any]] = [{"uid": self._resolve_to_user(entry), "rights": entry["rights"]} for entry in body] + entries: list[AclEntry] = self.module.patch_addressbook_share(self.user, key, users) + self._grant_folder_subs_keys([u["uid"] for u in users], key) + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("patch_addressbook_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + def put_addressbook_share(self, key: str, body: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Replace all user permissions for an address book. + + All existing permissions are replaced by the users specified in the request body. + + :param key: Address book key. + :param body: List of users (uid and rights) that becomes the full set of shares. + :return: API envelope with new user permissions. + """ + try: + previous_uids: set[str] = {entry.to_user for entry in self.module.get_addressbook_share(self.user, key)} + users: list[dict[str, Any]] = [{"uid": self._resolve_to_user(entry), "rights": entry["rights"]} for entry in body] + entries: list[AclEntry] = self.module.put_addressbook_share(self.user, key, users) + new_uids: set[str] = {u["uid"] for u in users} + self._grant_folder_subs_keys(new_uids, key) + for revoked_uid in previous_uids - new_uids: + if revoked_uid == cs.ANYONE_TO_USER: + continue + self._user_module.remove_folder_key(revoked_uid, "ADDRESSBOOKS", key, owner_key="SUBS") + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("put_addressbook_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + def post_addressbook_share(self, key: str, body: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Grant full permissions to one or several users. + + :param key: Address book key. + :param body: List of users (UIDs) to grant full permissions to. + :return: API envelope with updated user permissions. + """ + try: + target_uids: list[str] = [self._resolve_to_user(entry) for entry in body] + entries: list[AclEntry] = self.module.grant_addressbook_share(self.user, key, target_uids) + self._grant_folder_subs_keys(target_uids, key) + return create_api_base_response(self._serialize_share_entries(entries)) + except RequestException as ex: + logger_api.error("post_addressbook_share failed for user %s key %s: %s", self.user.uid, key, ex) + return create_api_base_response(None, ex.error) + + @staticmethod + def _resolve_to_user(entry: dict[str, Any]) -> str: + """A "anyone" user_class always collapses to the SOGo pseudo-user "".""" + if entry.get("user_class") == cs.USER_CLASS_ANY: + return cs.ANYONE_TO_USER + return entry["uid"] + + def _grant_folder_subs_keys(self, target_uids: Iterable[str], key: str) -> None: + """Add ``key`` to folders.ADDRESSBOOKS.SUBS for each target uid so it surfaces in their webmail. + + Cross-module orchestration (ModuleContact + ModuleUserProfile) is intentionally kept in + this interface layer, since a module must never call another module directly. + """ + for target_uid in target_uids: + if target_uid == cs.ANYONE_TO_USER: + continue # The "anyone" pseudo-user has no real folders to update, so skip it. + self._user_module.add_folder_key(target_uid, "ADDRESSBOOKS", key, owner_key="SUBS") + + def _serialize_share_entries(self, entries: list[AclEntry]) -> list[dict[str, Any]]: + """Resolve each ACL entry's to_user into the API's ContactShareUserSchema shape. + + A to_user not known by any user source is still returned (user_class ANON) so the caller + can see the raw grant instead of silently losing it. + """ + module_us: ModuleUserSource | None = None + result: list[dict[str, Any]] = [] + for entry in entries: + if entry.to_user == cs.ANYONE_TO_USER: + result.append({"c_email": "", "uid": "", "user_class": cs.USER_CLASS_ANY, "rights": entry.rights}) + continue + if module_us is None: + module_us = ModuleUserSource.init_from_domain_settings(self._user_domain_settings) + target: User = User(uid=entry.to_user) + module_us.get_contact_info_for_user(target) + result.append({ + "c_email": target.uid, #TODO provisoire pour l'UI, target.mail if not target.anonymous else "", #TODO : return empty string for unknown users? + "uid": entry.to_user, + "user_class": cs.USER_CLASS_ANON if target.anonymous else "", #TODO : quand on aura user sources? on mettra le user_class de la source, sinon on mettra ANON pour les inconnus? + "rights": entry.rights, + }) + return result + # # Contacts # @@ -144,6 +268,9 @@ def get_contacts( surfaced through the X-Pagination header (built by the pagination decorator) rather than the response body. ``search`` is a separate full-text query argument. + When scoped to one address book, the response also carries ``rights``: what the current user + may do on that book (see ModuleContact.get_addressbook_rights). + :param key: Address book key, or None to span all the user's books. :param collection_param: Parsed pagination and sort arguments from the request. :param search: Optional full-text query. @@ -161,7 +288,10 @@ def get_contacts( order=order, ) serialized: list[dict[str, Any]] = self._contacts_serializer.serialize(contacts) - return total, *create_api_base_response({"contacts": serialized}) + data: dict[str, Any] = {"contacts": serialized} + if key is not None: + data["rights"] = self.module.get_addressbook_rights(self.user, key) + return total, *create_api_base_response(data) except RequestException as ex: logger_api.error("get_contacts failed for user %s book %s: %s", self.user.uid, key, ex) return 0, *create_api_base_response(None, ex.error) diff --git a/app/interface/mail/InterfaceApiMailFolder.py b/app/interface/mail/InterfaceApiMailFolder.py index eb284fb0..35e96bb3 100644 --- a/app/interface/mail/InterfaceApiMailFolder.py +++ b/app/interface/mail/InterfaceApiMailFolder.py @@ -3,12 +3,15 @@ from http import HTTPStatus from app.auth.User import User -from app.module.mail.ModuleMail import ModuleMail +from app.factory.share.RepositoryAcl import AclEntry from app.module.auth.ModuleUserSource import ModuleUserSource +from app.module.mail.ModuleMail import ModuleMail +from app.factory.share.shareMailFolder import FOLDER_PERMISSION_CODE_TO_RIGHT from app.config.settings.DomainSettings import MailSettings, MailSettingsObj +from app.utils import constants as cs +from app.utils import errors as err from app.utils.exceptions import RequestException from app.utils.api.ApiBaseResponse import create_api_base_response -from app.utils import constants as cs from app.utils.logger.logger import logger_api if TYPE_CHECKING: @@ -28,7 +31,7 @@ def __init__(self, process_setting: ProcessSetting, user_domain_settings: dict, self.mail_settings = MailSettingsObj(user_domain_settings[MailSettings.subparent]) self.user = user - self.mail_module = ModuleMail(self.user, self.mail_settings) + self.mail_module = ModuleMail(self.user, self.mail_settings, process_setting=process_setting) def get_folder_list(self, account_id: str) -> tuple[dict[str, Any], int]: """Retrieve the list of mail folders for a given account and return an ApiBaseResponse. @@ -186,7 +189,7 @@ def export_folder_mails(self, account_id: str, folder_name: str) -> tuple[dict[s def get_folder_share(self, account_id: str, folder_path: str) -> tuple[dict[str, Any], int]: """Get share information for the specified folder. - + :param account_id: The ID of the account :type account_id: str :param folder_path: The ID of the folder @@ -195,112 +198,147 @@ def get_folder_share(self, account_id: str, folder_path: str) -> tuple[dict[str, :rtype: tuple[dict[str, Any], int] """ try: - share_info: dict[str, dict[str, Any]] = {} - - # Only Instantiate Module User Source if we need it - module_us: ModuleUserSource|None = None - - for identifier, rights in self.mail_module.get_folder_share(account_id, folder_path): - if identifier == self.user.login_mail_server: - continue - if identifier == "anyone": - #Special indentifier means it is acl for everyone than can auth on the mail server - share_info[identifier] = { - "user_class": cs.USER_CLASS_ANY, - "c_email": "", - "cn": "", - "uid": "", - "rights": rights - } - continue - - if module_us is None: - module_us = ModuleUserSource.init_from_domain_settings(self.user_domain_settings) - #See if the identifier is known by us - user = User(identifier) - user.source_id = self.user.source_id - module_us.get_contact_info_for_user(user) - if user.anonymous: - #The user was not found - share_info[identifier] = { - "user_class": cs.USER_CLASS_ANON, - "c_email": "", - "cn": "", - "uid": identifier, - "rights": rights - } - else: - #TODO handlre groups. They start with '@' - share_info[identifier] = { - "user_class": cs.USER_CLASS_USER, - "c_email": user.mail, - "cn": user.cn, - "uid": user.uid, - "rights": rights - } - return create_api_base_response(share_info) + entries: list[AclEntry] = self.mail_module.get_folder_share(account_id, folder_path) except RequestException as ex: logger_api.error("Request exception in get_folder_share: %s", str(ex)) return create_api_base_response(None, ex.error) + return create_api_base_response(self._serialize_share_entries(entries)) + + def patch_folder_share(self, account_id: str, folder_path: str, share_data: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Partially update sharing rights for the specified folder. + + Only the users specified in share_data are modified; other existing shares are + left unchanged. - def share_folder(self, account_id: str, folder_path: str, share_data: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: - """Share the specified folder with another user. - :param account_id: The ID of the account :type account_id: str :param folder_path: The ID of the folder :type folder_path: str :param share_data: List of users with their rights configuration - :type share_data: List[dict[str, Any]] + :type share_data: list[dict[str, Any]] :return: A tuple of (API response dict, status code) :rtype: tuple[dict[str, Any], int] """ try: - share_info: dict[str, dict[str, Any]] = {} - - # Only Instantiate Module User Source if we need it - module_us: ModuleUserSource|None = None - - for identifier, rights in self.mail_module.share_folder(account_id, folder_path, share_data): - #TODO find a clerver way to factor this loop with get_folder_share() - if identifier == self.user.login_mail_server: - continue - if identifier == "anyone": - #Special indentifier means it is acl for everyone than can auth on the mail server - share_info[identifier] = { - "user_class": cs.USER_CLASS_ANY, - "c_email": "", - "cn": "", - "uid": "", - "rights": rights - } - continue - - if module_us is None: - module_us = ModuleUserSource.init_from_domain_settings(self.user_domain_settings) - #See if the identifier is known by us - user = User(identifier) - module_us.get_contact_info_for_user(user) - if user.anonymous: - #The user was not found - share_info[identifier] = { - "user_class": cs.USER_CLASS_ANON, - "c_email": "", - "cn": "", - "uid": identifier, - "rights": rights - } - else: - #TODO handlre groups. They start with '@' - share_info[identifier] = { - "user_class": cs.USER_CLASS_USER, - "c_email": user.mail, - "cn": user.cn, - "uid": user.uid, - "rights": rights - } - - return create_api_base_response(share_info) + users = [{"uid": self._resolve_to_user(entry), "rights": self._resolve_rights(entry)} for entry in share_data] + entries: list[AclEntry] = self.mail_module.patch_folder_share(account_id, folder_path, users) except RequestException as ex: - logger_api.error("Request exception in share_folder: %s", str(ex)) + logger_api.error("Request exception in patch_folder_share: %s", str(ex)) return create_api_base_response(None, ex.error) + return create_api_base_response(self._serialize_share_entries(entries)) + + def put_folder_share(self, account_id: str, folder_path: str, share_data: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Replace all sharing rights for the specified folder. + + Existing shares are entirely replaced by the users specified in share_data. + + :param account_id: The ID of the account + :type account_id: str + :param folder_path: The ID of the folder + :type folder_path: str + :param share_data: List of users with their rights configuration + :type share_data: list[dict[str, Any]] + :return: A tuple of (API response dict, status code) + :rtype: tuple[dict[str, Any], int] + """ + try: + users = [{"uid": self._resolve_to_user(entry), "rights": self._resolve_rights(entry)} for entry in share_data] + entries: list[AclEntry] = self.mail_module.put_folder_share(account_id, folder_path, users) + except RequestException as ex: + logger_api.error("Request exception in put_folder_share: %s", str(ex)) + return create_api_base_response(None, ex.error) + return create_api_base_response(self._serialize_share_entries(entries)) + + def post_folder_share(self, account_id: str, folder_path: str, share_data: list[dict[str, Any]]) -> tuple[dict[str, Any], int]: + """Grant sharing rights on the specified folder to one or several users. + + :param account_id: The ID of the account + :type account_id: str + :param folder_path: The ID of the folder + :type folder_path: str + :param share_data: List of users with their rights configuration + :type share_data: list[dict[str, Any]] + :return: A tuple of (API response dict, status code) + :rtype: tuple[dict[str, Any], int] + """ + try: + users = [{"uid": self._resolve_to_user(entry), "rights": self._resolve_rights(entry)} for entry in share_data] + entries: list[AclEntry] = self.mail_module.post_folder_share(account_id, folder_path, users) + except RequestException as ex: + logger_api.error("Request exception in post_folder_share: %s", str(ex)) + return create_api_base_response(None, ex.error) + return create_api_base_response(self._serialize_share_entries(entries)) + + def _resolve_to_user(self, entry: dict[str, Any]) -> str: + """Resolve the ACL to_user for a share entry. + + A "anyone" user_class always collapses to the SOGo pseudo-user "" in + sogo6_acl.to_user, regardless of whatever uid the caller may have supplied. + """ + if entry.get("user_class") == cs.USER_CLASS_ANY: + return cs.ANYONE_TO_USER + return entry["uid"] + + @staticmethod + def _resolve_rights(entry: dict[str, Any]) -> dict[str, int]: + """Build the full rights dict (one 0/1 flag per IMAP ACL right) for a share entry. + + When ``permissions`` is provided, any right not listed is not granted (0) - it fully + determines the entry's rights. When only ``rights`` is provided, any right it omits is + likewise not granted (0). When both are provided, they must agree on every right + ``permissions`` covers (i.e. every right, since an omitted code means "not granted"). + + :raises RequestException: ERROR_SHARE_PERMISSIONS_RIGHTS_MISMATCH if permissions and + rights disagree on a right they both cover. + """ + permissions: list[str] | None = entry.get("permissions") + rights_in: dict[str, int] = entry.get("rights") or {} + + if permissions is not None: + derived = {right: (1 if code in permissions else 0) for code, right in FOLDER_PERMISSION_CODE_TO_RIGHT.items()} + for right_name, value in rights_in.items(): + if derived.get(right_name) != value: + raise RequestException(error=err.ERROR_SHARE_PERMISSIONS_RIGHTS_MISMATCH) + return derived + + resolved: dict[str, int] = dict.fromkeys(FOLDER_PERMISSION_CODE_TO_RIGHT.values(), 0) + resolved.update(rights_in) + return resolved + + @staticmethod + def _snake_to_camel(name: str) -> str: + """Convert a snake_case right name (e.g. "user_can_view_folder") to camelCase.""" + first, *rest = name.split("_") + return first + "".join(word.capitalize() for word in rest) + + def _serialize_share_entries(self, entries: list[AclEntry]) -> list[dict[str, Any]]: + """Resolve ACL entries into the API's FolderShareResponseSchema shape. + + A to_user not known by any user source is still returned (user_class ANON) so the + caller can see the raw grant instead of silently losing it. The "" pseudo + to_user is the "anyone" share and is never resolved through the user source. + """ + module_us: ModuleUserSource | None = None + users: list[dict[str, Any]] = [] + for entry in entries: + granted_rights = {self._snake_to_camel(right): 1 for right, value in entry.rights.items() if value} + if entry.to_user == cs.ANYONE_TO_USER: + users.append({ + "user_class": cs.USER_CLASS_ANY, + "cn": "Tout utilisateur identifié", + "uid": cs.USER_CLASS_ANY, + "rights": granted_rights, + }) + continue + if module_us is None: + module_us = ModuleUserSource.init_from_domain_settings(self.user_domain_settings) + target: User = User(uid=entry.to_user) + module_us.get_contact_info_for_user(target) + users.append({ + "user_class": cs.USER_CLASS_ANON if target.anonymous else cs.USER_CLASS_USER, + "c_email": target.uid, + "cn": target.cn, + "uid": entry.to_user, + "rights": granted_rights, + }) + return users diff --git a/app/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..ad904ce1 100644 --- a/app/manager/mail/ClientImap.py +++ b/app/manager/mail/ClientImap.py @@ -1044,6 +1044,73 @@ def delete_acl(self, folder_path: str, identifier: str) -> None: else: raise BugException("Not authenticated meaning self.connect() and self.login() was not called beforehands") + def get_acl_raw(self, folder_path: str) -> Iterator[tuple[str, str]]: + """Get the raw Access Control List (ACL) for a folder, with no SOGo rights conversion. + + Uses the IMAP GETACL command and yields the IMAP rights characters exactly as returned + by the server (e.g. "lrswipkxtea"), for callers that already work with their own + rights-code correspondence table instead of the legacy SOGo rights dictionary. + + :param folder_path: The name of the folder to get ACL for. + :type folder_path: str + :yield: tuples of (identifier, imap_rights) where imap_rights is the raw ACL string. + :rtype: Iterator[tuple[str, str]] + :raises RequestException: If not connected to the server or if getting ACL fails. + """ + logger_imap.debug("Getting raw ACL for folder '%s'", folder_path) + if self.connection is not None and self.authenticated: + folder_path = self._fix_folder_path(folder_path) + folder_path = quote(folder_path) + success, datas = self._exec_imap4_method(self.connection.getacl, folder_path) + if not success: + first_error = datas[0] if isinstance(datas[0], str) else datas[0].decode() + if first_error.startswith("Mailbox doesn't exist"): + raise RequestException(f"Folder '{folder_path}' does not exist", err.ERROR_FOLDER_NAME_NOT_FOUND) + raise RequestException(f"Failed to get ACL for {folder_path}: {first_error}", err.ERROR_IMAP_FAILED) + + # Parse the response: data[0] is typically bytes like b'INBOX identifier1 rights1 identifier2 rights2 ...' + parts = datas[0].decode().split() + + # Skip first part (folder name) and yield identifier/rights pairs + i = 1 + while i < len(parts) - 1: + yield (parts[i], parts[i + 1]) + i += 2 + else: + raise BugException("Not authenticated meaning self.connect() and self.login() was not called beforehands") + + def set_acl_raw(self, folder_path: str, identifier: str, imap_rights: str) -> None: + """Set ACL rights for a specific user/identifier on a folder, with no SOGo rights conversion. + + Uses the IMAP SETACL command directly with the given IMAP rights characters (e.g. + "lrswipkxtea"), for callers that already work with their own rights-code correspondence + table instead of the legacy SOGo rights dictionary. + + :param folder_path: The name of the folder. + :type folder_path: str + :param identifier: The user identifier (email, username, or special like 'anyone'). + :type identifier: str + :param imap_rights: Raw IMAP ACL rights characters to grant (empty string revokes all). + :type imap_rights: str + :raises RequestException: If not connected to the server or if setting ACL fails. + """ + logger_imap.debug("Setting raw ACL for folder '%s', identifier '%s', IMAP rights '%s'", folder_path, identifier, imap_rights) + if self.connection is not None and self.authenticated: + folder_path = self._fix_folder_path(folder_path) + folder_path = quote(folder_path) + + success, datas = self._exec_imap4_method(self.connection.setacl, folder_path, identifier, imap_rights) + if not success: + first_error = datas[0] if isinstance(datas[0], str) else datas[0].decode() + if first_error.startswith("Mailbox doesn't exist"): + raise RequestException(f"Folder '{folder_path}' does not exist", err.ERROR_FOLDER_NAME_NOT_FOUND) + raise RequestException(f"Failed to set ACL for {folder_path}: {first_error}", err.ERROR_IMAP_FAILED) + + logger_imap.info("Successfully set raw ACL for folder '%s', identifier '%s', IMAP rights '%s'", + folder_path, identifier, imap_rights) + else: + raise BugException("Not authenticated meaning self.connect() and self.login() was not called beforehands") + ####### #MAILS# ####### @@ -1648,7 +1715,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/manager/mail/ClientMailServer.py b/app/manager/mail/ClientMailServer.py index 79fd8ef2..55878820 100644 --- a/app/manager/mail/ClientMailServer.py +++ b/app/manager/mail/ClientMailServer.py @@ -113,6 +113,21 @@ def get_acl(self, folder_path: str) -> Iterator[tuple[str, dict[str, int]]]: :raises RequestException: If not connected to the server or if getting ACL fails. """ + @abstractmethod + def get_acl_raw(self, folder_path: str) -> Iterator[tuple[str, str]]: + """Get the raw Access Control List (ACL) for a folder, with no SOGo rights conversion. + + Uses the IMAP GETACL command and yields the IMAP rights characters exactly as returned + by the server (e.g. "lrswipkxtea"), for callers that already work with their own + rights-code correspondence table instead of the legacy SOGo rights dictionary. + + :param folder_path: The name of the folder to get ACL for. + :type folder_path: str + :yield: tuples of (identifier, imap_rights) where imap_rights is the raw ACL string. + :rtype: Iterator[tuple[str, str]] + :raises RequestException: If not connected to the server or if getting ACL fails. + """ + @abstractmethod def set_acl(self, folder_path: str, identifier: str, rights: dict[str, Any]) -> None: """Set ACL rights for a specific user/identifier on a folder. diff --git a/app/module/calendar/ModuleCalendar.py b/app/module/calendar/ModuleCalendar.py index 574bfad7..1a353c6c 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( @@ -200,6 +298,11 @@ def get_event(self, calendar_user: CalendarUser, event_key: str) -> CalEvent: _, event = self._sources.require_event(calendar_user.owner.uid, event_key) return event + def get_event_permissions(self, calendar_user: CalendarUser, event_key: str) -> CalendarPermissions: + """Return the acting user's permissions on the calendar holding the given event, or raise NOT_FOUND.""" + source, _ = self._sources.require_event(calendar_user.owner.uid, event_key) + return self._acl.get_permissions(source.calendar, calendar_user) + def update_event(self, calendar_user: CalendarUser, event_key: str, event_update: CalEvent, organizer: CalOrganizer) -> CalEvent: """Update an event, handling recurrence scope and attendee propagation.""" source, event = self._sources.require_event(calendar_user.owner.uid, event_key) @@ -380,7 +483,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..1bc5be9f 100644 --- a/app/module/calendar/serializer/CalCalendarSerializerDict.py +++ b/app/module/calendar/serializer/CalCalendarSerializerDict.py @@ -27,5 +27,6 @@ def serialize(self, data: CalCalendar) -> dict[str, Any]: "default_event_duration_min": data.default_event_duration_min, "default_alarm_duration_min": data.default_alarm_duration_min, "default_type": data.default_type.value if data.default_type else None, - "permissions": self._permissions_serializer.serialize(data.permissions) if data.permissions else None, + "rights": self._permissions_serializer.serialize(data.permissions) if data.permissions else None, + "owner": data.user_uid, } diff --git a/app/module/calendar/serializer/CalendarPermissionsSerializerDict.py b/app/module/calendar/serializer/CalendarPermissionsSerializerDict.py index 066ecb3c..fca74040 100644 --- a/app/module/calendar/serializer/CalendarPermissionsSerializerDict.py +++ b/app/module/calendar/serializer/CalendarPermissionsSerializerDict.py @@ -2,18 +2,29 @@ from typing import Any +from app.factory.share.shareCalendar import LEVEL_TO_STR from app.module.calendar.model.CalendarPermissions import CalendarPermissions +from app.module.calendar.model.enums.CalendarShareLevel import CalendarShareLevel from app.utils.serializer.Serializer import Serializer class CalendarPermissionsSerializerDict(Serializer[CalendarPermissions, dict[str, Any]]): - """Serializes CalendarPermissions to a dict for API responses.""" + """Serializes CalendarPermissions to the API ``rights`` dict. + + Same shape and level strings as the sharing API (CalendarShareRightsSchema), which is also + the blob stored in sogo6_acl. + """ def serialize(self, data: CalendarPermissions) -> dict[str, Any]: return { - "public_level": data.public_level.name.lower(), - "confidential_level": data.confidential_level.name.lower(), - "private_level": data.private_level.name.lower(), - "can_create": data.can_create, - "can_delete": data.can_delete, + "public": self._level(data.public_level), + "confidential": self._level(data.confidential_level), + "private": self._level(data.private_level), + "can_create_objects": data.can_create, + "can_erase_objects": data.can_delete, } + + @staticmethod + def _level(level: CalendarShareLevel) -> str: + # MODIFY_IF_ORG has no API string: outside the organizer's own events it behaves as RESPOND. + return LEVEL_TO_STR.get(level, LEVEL_TO_STR[CalendarShareLevel.RESPOND]) diff --git a/app/module/calendar/serializer/CalendarShareDeserializerDict.py b/app/module/calendar/serializer/CalendarShareDeserializerDict.py index 9ddc598e..2e77bc97 100644 --- a/app/module/calendar/serializer/CalendarShareDeserializerDict.py +++ b/app/module/calendar/serializer/CalendarShareDeserializerDict.py @@ -2,8 +2,8 @@ from typing import Any +from app.factory.share.shareCalendar import STR_TO_LEVEL from app.module.calendar.model.CalendarShare import CalendarShare -from app.module.calendar.model.enums.CalendarShareLevel import CalendarShareLevel from app.utils.serializer.Deserializer import Deserializer @@ -14,9 +14,9 @@ def deserialize(self, data: dict[str, Any]) -> CalendarShare: return CalendarShare( user_uid=data["user_uid"], calendar_key=data["calendar_key"], - public_level=CalendarShareLevel[data.get("public_level", "none").upper()], - confidential_level=CalendarShareLevel[data.get("confidential_level", "none").upper()], - private_level=CalendarShareLevel[data.get("private_level", "none").upper()], - can_create=data.get("can_create", False), - can_delete=data.get("can_delete", False), + public_level=STR_TO_LEVEL[data.get("public", "none")], + confidential_level=STR_TO_LEVEL[data.get("confidential", "none")], + private_level=STR_TO_LEVEL[data.get("private", "none")], + can_create=data.get("can_create_objects", False), + can_delete=data.get("can_erase_objects", False), ) diff --git a/app/module/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..480e88e2 100644 --- a/app/module/contact/ModuleContact.py +++ b/app/module/contact/ModuleContact.py @@ -8,6 +8,7 @@ ALLOWED_FILE_MIME_TYPES, DEFAULT_ADDRESSBOOK_NAME, FILE_MAX_SIZE_KB, IMPORT_MAX_BYTES, ) from app.module.contact.acl.ContactAclEngine import ContactAclEngine +from app.factory.share.shareContact import FULL_MODIFY_RIGHTS, ShareContact from app.module.contact.jobs.ContactJobKind import ContactJobKind from app.module.contact.jobs.JobRequestExportContact import JobRequestExportContact from app.module.contact.jobs.JobRequestImportContact import JobRequestImportContact @@ -28,6 +29,7 @@ from app.auth.User import User from app.config.settings.DomainSettings import UserSourceSettingsObj from app.config.settings.ProcessSetting import ProcessSetting + from app.factory.share.RepositoryAcl import AclEntry from app.manager.agent.ClientAgent import ClientAgent from app.manager.cache.ClientRedis import ClientRedis from app.manager.db.ClientSQL import ClientSQL @@ -52,8 +54,9 @@ def __init__( self._db.connect() self._cache: ClientRedis | None = cache self._agent: ClientAgent | None = agent - self._sources: ContactSources = ContactSources(self._db) - self._acl: ContactAclEngine = ContactAclEngine() + self._share: ShareContact = ShareContact(self._db) + self._sources: ContactSources = ContactSources(self._db, share=self._share) + self._acl: ContactAclEngine = ContactAclEngine(share=self._share) self._file: ClientStorage = import_and_instantiate_manager( module_path="app.manager.storage", module_and_class_name=f"ClientStorage{process_settings.SOGO_P_STORAGE_TYPE.capitalize()}", @@ -71,7 +74,7 @@ def create_personal_addressbook(self, user_uid: str, name: str = DEFAULT_ADDRESS a new one. Called at first login alongside the personal calendar provisioning. """ for source in self._sources.get_all(user_uid): - if source.addressbook.is_default: + if source.addressbook.is_default and source.addressbook.user_uid == user_uid: return source.addressbook book: CardAddressBook = CardAddressBook( user_uid=user_uid, name=name, is_default=True, source_type=CardSourceType.LOCAL, @@ -86,7 +89,7 @@ def create_personal_addressbook(self, user_uid: str, name: str = DEFAULT_ADDRESS def get_all_addressbooks( self, user: User, user_sources: dict[str, UserSourceSettingsObj] | None = None, ) -> list[CardAddressBook]: - """Return all address books owned by the user (local DB books; directory when user_sources set).""" + """Return all address books owned by or shared with the user (local DB books; directory when user_sources set).""" return [source.addressbook for source in self._sources.get_all(user.uid, user_sources)] def get_addressbook( @@ -118,6 +121,21 @@ def _get_writable_addressbook( self._require_modify(source, user) return source + def get_addressbook_rights( + self, user: User, key: str, user_sources: dict[str, UserSourceSettingsObj] | None = None, + ) -> dict[str, bool]: + """Return the acting user's own rights on an address book, in the ContactShareRightsSchema shape. + + The owner has every right. Any other user gets the rights of their sogo6_acl entry (their own, + or the "anyone" one), each flag defaulting to False; no entry means no right at all. + """ + book: CardAddressBook = self.get_addressbook(user, key, user_sources).addressbook + if book.user_uid == user.uid: + return dict(FULL_MODIFY_RIGHTS) + entry: AclEntry | None = self._share.get_user_or_anyone(user.uid, book.user_uid, key) + stored: dict = entry.rights if entry is not None else {} + return {name: bool(stored.get(name, False)) for name in FULL_MODIFY_RIGHTS} + def create_addressbook( self, user: User, book: CardAddressBook, user_sources: dict[str, UserSourceSettingsObj] | None = None, ) -> CardAddressBook: @@ -141,10 +159,93 @@ def update_addressbook( def delete_addressbook( self, user: User, key: str, hard_delete: bool = False, user_sources: dict[str, UserSourceSettingsObj] | None = None, - ) -> None: - """Delete an address book; its contacts are tombstoned and detached (soft) or removed (hard).""" + ) -> list[str]: + """Delete an address book; its contacts are tombstoned and detached (soft) or removed (hard). + + Also cleans up any sogo6_acl rows granting other users access to this address book. + + :return: the list of uids that had a share on this address book (so the interface layer can + clean up their folders.ADDRESSBOOKS.SUBS entry too). + """ source: ContactSource = self._get_writable_addressbook(user, key, user_sources) + shared_uids: list[str] = [entry.to_user for entry in self._share.get_permissions(key)] source.delete_addressbook(hard_delete=hard_delete) + self._share.remove_all_permissions_for_key(key) + return shared_uids + + # + # Address book sharing + # + def _require_owned_addressbook(self, user: User, key: str) -> ContactSource: + """Return the address book source, raising ACCESS_DENIED if user is not its owner. + + Sharing management (list / grant / patch / put) is an owner-only operation: get_addressbook + now also resolves address books merely shared with user, so an explicit ownership check is + required here to prevent a sharee from managing the resource's ACL. + """ + source: ContactSource = self.get_addressbook(user, key) + if source.addressbook.user_uid != user.uid: + raise RequestException(error=err.ERROR_CONTACT_ACCESS_DENIED) + return source + + def get_addressbook_share(self, user: User, key: str) -> list[AclEntry]: + """Return all ACL entries (one per user) granted on the address book identified by key. + + The caller must be the owner of the address book. + """ + self._require_owned_addressbook(user, key) + return self._share.get_permissions(key) + + def grant_addressbook_share(self, user: User, key: str, target_uids: list[str]) -> list[AclEntry]: + """Grant full permissions on the address book to one or several users. + + :param user: the acting user, must own the address book. + :param key: opaque key of the address book to share. + :param target_uids: uids to grant full permissions to. + :raises RequestException: ERROR_CONTACT_ADDRESSBOOK_NOT_FOUND if the address book does not + exist; ERROR_CONTACT_ACCESS_DENIED if user does not own it; + ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself. + """ + book: CardAddressBook = self._require_owned_addressbook(user, key).addressbook + for target_uid in target_uids: + self._share.add_permissions(target_uid, key, book.user_uid, dict(FULL_MODIFY_RIGHTS)) + return self._share.get_permissions(key) + + def patch_addressbook_share(self, user: User, key: str, users: list[dict]) -> list[AclEntry]: + """Grant or update rights for one or several users, leaving other existing shares untouched. + + :param user: the acting user, must own the address book. + :param key: opaque key of the address book to share. + :param users: list of ``{"uid": ..., "rights": {...}}`` entries to upsert. + :raises RequestException: ERROR_CONTACT_ADDRESSBOOK_NOT_FOUND if the address book does not + exist; ERROR_CONTACT_ACCESS_DENIED if user does not own it; + ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself. + """ + book: CardAddressBook = self._require_owned_addressbook(user, key).addressbook + for entry in users: + self._share.add_permissions(entry["uid"], key, book.user_uid, entry["rights"]) + return self._share.get_permissions(key) + + def put_addressbook_share(self, user: User, key: str, users: list[dict]) -> list[AclEntry]: + """Replace all existing shares on the address book with exactly the given users' rights. + + Any user currently shared with but absent from ``users`` is revoked. + + :param user: the acting user, must own the address book. + :param key: opaque key of the address book to share. + :param users: list of ``{"uid": ..., "rights": {...}}`` entries; becomes the full set of shares. + :raises RequestException: ERROR_CONTACT_ADDRESSBOOK_NOT_FOUND if the address book does not + exist; ERROR_CONTACT_ACCESS_DENIED if user does not own it; + ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself. + """ + book: CardAddressBook = self._require_owned_addressbook(user, key).addressbook + new_uids: set[str] = {entry["uid"] for entry in users} + for existing in self._share.get_permissions(key): + if existing.to_user not in new_uids: + self._share.remove_permissions(existing.to_user, key) + for entry in users: + self._share.add_permissions(entry["uid"], key, book.user_uid, entry["rights"]) + return self._share.get_permissions(key) # # Contacts diff --git a/app/module/contact/acl/ContactAclEngine.py b/app/module/contact/acl/ContactAclEngine.py index 0ff4b82d..db5dc67b 100644 --- a/app/module/contact/acl/ContactAclEngine.py +++ b/app/module/contact/acl/ContactAclEngine.py @@ -8,27 +8,38 @@ if TYPE_CHECKING: from app.auth.User import User + from app.factory.share.RepositoryAcl import AclEntry + from app.factory.share.shareContact import ShareContact from app.module.contact.model.CardAddressBook import CardAddressBook class ContactAclEngine: """Resolves and enforces address book permissions. - Centralizes contact ACL logic: access-level resolution and action checks. Currently stubbed - - the owner gets MODIFY on their own books, a non-owner is denied. Will be connected to the - centralized ACL module (internal sharing) when it is implemented. + Centralizes contact ACL logic: access-level resolution and action checks. Owner gets full + access; a non-owner's level is resolved from the sogo6_acl-backed ``ShareContact`` when one + is supplied, denied otherwise (e.g. legacy/unit-test callers that construct the engine + without a share resolver). """ + def __init__(self, share: ShareContact | None = None) -> None: + self._share: ShareContact | None = share + def get_share_level(self, addressbook: CardAddressBook, user: User) -> ContactShareLevel | None: """Resolve the acting user's access level on an address book, or None when denied. - The owner gets MODIFY on their own books; a non-owner is denied (stub) until the ACL module - provides shared levels. + The owner gets MODIFY on their own books. A non-owner's level comes from the sogo6_acl + entry granted on this book (see ShareContact.get_user_or_anyone), or denied when none + exists or no share resolver was supplied. """ if addressbook.user_uid == user.uid: return ContactShareLevel.MODIFY - # TODO: look up shared permissions from the centralized ACL module - return None + if self._share is None or addressbook.key is None: + return None + entry: AclEntry | None = self._share.get_user_or_anyone(user.uid, addressbook.user_uid, addressbook.key) + if entry is None: + return None + return self._share.to_share_level(entry.rights) def check_permission(self, level: ContactShareLevel | None, required: ContactShareLevel) -> None: """Raise ERROR_CONTACT_ACCESS_DENIED when the resolved level is below the required one. diff --git a/app/module/contact/repository/RepositoryAddressBook.py b/app/module/contact/repository/RepositoryAddressBook.py index 137abc7a..9c175ec8 100644 --- a/app/module/contact/repository/RepositoryAddressBook.py +++ b/app/module/contact/repository/RepositoryAddressBook.py @@ -102,6 +102,23 @@ def find_by_key(self, user_uid: str, key: str) -> CardAddressBook | None: return None return self._row_to_addressbook(rows[0]) + def find_by_key_only(self, key: str) -> CardAddressBook | None: + """Return the address book matching key, regardless of owner. + + Unlike find_by_key, not scoped to a user_uid: used by the sharing feature, where the + caller (a prospective sharee, or the share management module) does not necessarily own + the address book. The key itself (an opaque generated uuid) is the lookup capability. + """ + rows = list(self._db.select_from_table( + table_name=tbl.TABLE_ADDRESSBOOK.name, + column_tuple=_ALL_COLS, + condition=EqualCondition(tbl.COL_AB_KEY.name, key), + limit=1, + )) + if not rows: + return None + return self._row_to_addressbook(rows[0]) + def get_default_for_user(self, user_uid: str) -> CardAddressBook | None: """Return the default address book for user_uid, or None if not found.""" condition = AndCondition( diff --git a/app/module/contact/serializer/CardAddressBookSerializerDict.py b/app/module/contact/serializer/CardAddressBookSerializerDict.py index 05aaa834..689d34b6 100644 --- a/app/module/contact/serializer/CardAddressBookSerializerDict.py +++ b/app/module/contact/serializer/CardAddressBookSerializerDict.py @@ -19,4 +19,5 @@ def serialize(self, data: CardAddressBook) -> dict[str, Any]: "is_default": data.is_default, "source_type": data.source_type.value, "ctag": data.ctag, + "owner": data.user_uid, } diff --git a/app/module/contact/source/ContactSources.py b/app/module/contact/source/ContactSources.py index c7c969e8..d2016442 100644 --- a/app/module/contact/source/ContactSources.py +++ b/app/module/contact/source/ContactSources.py @@ -8,13 +8,16 @@ from app.module.contact.repository.RepositoryContact import RepositoryContact from app.module.contact.repository.RepositoryContactList import RepositoryContactList from app.module.contact.source.ContactSourceDb import SORTABLE_COLUMNS, ContactSourceDb +from app.utils import constants as cs from app.utils import errors as err from app.utils.db.Condition import Order from app.utils.exceptions import RequestException from app.utils.logger.logger import logger_contact +from app.utils.strings import get_domain_from_mail if TYPE_CHECKING: from app.config.settings.DomainSettings import UserSourceSettingsObj + from app.factory.share.shareContact import ShareContact from app.manager.db.ClientSQL import ClientSQL from app.manager.storage.ClientStorage import ClientStorage from app.module.contact.model.CardAddressBook import CardAddressBook @@ -35,9 +38,10 @@ class ContactSources: for the annuaire (SQL or LDAP), one ContactSourceDirectory per source. """ - def __init__(self, db: ClientSQL) -> None: + def __init__(self, db: ClientSQL, share: ShareContact | None = None) -> None: self._db = db self._repo_addressbook = RepositoryAddressBook(db) + self._share: ShareContact | None = share def purge_orphans(self, file_store: ClientStorage) -> int: """Physically remove soft-deleted rows, dangling list memberships and orphan media; return total reclaimed. @@ -68,9 +72,39 @@ def get(self, addressbook: CardAddressBook, user_sources: dict[str, UserSourceSe logger_contact.error("Unknown source_type=%s for address book key=%s", addressbook.source_type, addressbook.key) raise RequestException(error=err.ERROR_CONTACT_ADDRESSBOOK_NOT_SUPPORTED) + def _get_shared_addressbooks(self, user_uid: str) -> list[CardAddressBook]: + """Return every address book shared with user_uid, directly or via an "anyone" share. + + Directly: sogo6_acl entries where to_user=user_uid. Via "anyone": sogo6_acl entries where + to_user="", restricted to books whose owner shares user_uid's mail domain (see + ShareContact.get_user_or_anyone). Skips entries whose key no longer resolves to a book + (deleted resource, stale ACL row) and books owned by user_uid. + """ + if self._share is None: + return [] + shared: list[CardAddressBook] = [] + seen_keys: set[str] = set() + for entry in self._share.get_keys_shared_with(user_uid): + book: CardAddressBook | None = self._repo_addressbook.find_by_key_only(entry.key) + if book is not None and book.key not in seen_keys and book.user_uid != user_uid: + shared.append(book) + seen_keys.add(book.key) + user_domain: str | None = get_domain_from_mail(user_uid) + if user_domain: + for entry in self._share.get_keys_shared_with(cs.ANYONE_TO_USER): + if entry.key in seen_keys: + continue + book = self._repo_addressbook.find_by_key_only(entry.key) + if (book is not None and book.user_uid != user_uid + and get_domain_from_mail(book.user_uid) == user_domain): + shared.append(book) + seen_keys.add(entry.key) + return shared + def get_all(self, user_uid: str, user_sources: dict[str, UserSourceSettingsObj] | None = None) -> list[ContactSource]: - """Return a source for every address book owned by user_uid (local books; directory later).""" - return [self.get(book, user_sources) for book in self._repo_addressbook.find_all(user_uid)] + """Return a source for every address book owned by, or shared with, user_uid (directory later).""" + owned: list[CardAddressBook] = self._repo_addressbook.find_all(user_uid) + return [self.get(book, user_sources) for book in owned + self._get_shared_addressbooks(user_uid)] def get_default(self, user_uid: str) -> ContactSource | None: """Return the default address book source for user_uid, or None if the user has none.""" @@ -80,12 +114,21 @@ def get_default(self, user_uid: str) -> ContactSource | None: def get_by_key( self, user_uid: str, key: str, user_sources: dict[str, UserSourceSettingsObj] | None = None, ) -> ContactSource | None: - """Return the source for a specific address book, or None if not found.""" + """Return the source for a specific address book, or None if not found. + + Resolves address books owned by user_uid, shared with user_uid directly (sogo6_acl), + and shared with "anyone" when user_uid shares the owner's mail domain (see + ShareContact.get_user_or_anyone). + """ # TODO directory: route on the key. Directory books carry a reserved "dir:" # prefix (a raw UUID never starts with it), so the branch is unambiguous: strip the prefix, # look the source_uid up in user_sources, build a synthetic directory book. A plain UUID # falls through to the DB lookup below. Blocked on the user source query primitive. book = self._repo_addressbook.find_by_key(user_uid, key) + if book is None and self._share is not None: + candidate: CardAddressBook | None = self._repo_addressbook.find_by_key_only(key) + if candidate is not None and self._share.get_user_or_anyone(user_uid, candidate.user_uid, key) is not None: + book = candidate return self.get(book, user_sources) if book is not None else None def get_contacts( # pylint: disable=too-many-locals diff --git a/app/module/mail/ModuleMail.py b/app/module/mail/ModuleMail.py index a5d9ee16..181bed9d 100644 --- a/app/module/mail/ModuleMail.py +++ b/app/module/mail/ModuleMail.py @@ -8,12 +8,17 @@ from email.message import EmailMessage from email.message import Message from email.utils import parseaddr, getaddresses, make_msgid, formatdate +from hashlib import sha256 from io import BytesIO from re import search as reg_search import zipfile from app.config.settings.UserSettings import UserMailViewSettings, UserMailViewSettingsObj, UserMailGeneralSettings +from app.factory.share.RepositoryAcl import AclEntry +from app.factory.share.shareMailFolder import ( + FOLDER_RESOURCE_TYPE, ShareMailFolder, imap_permissions_to_rights, rights_to_imap_permissions, +) from app.module.mail.model.TmpDraftManager import TmpDraftManager from app.manager.mail.ClientMailServer import ClientMailServer from app.utils import constants as cs @@ -22,7 +27,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: @@ -49,6 +54,7 @@ def __init__(self, user: User, mail_settings: MailSettingsObj, process_setting: self.domain_mail_folder_name: dict = {} self._process_setting: ProcessSetting | None = process_setting self._db: ClientSQL | None = None + self._share: ShareMailFolder | None = None def _get_db(self) -> ClientSQL: """Return the DB client, lazily initialising it on first call. @@ -68,6 +74,23 @@ def _get_db(self) -> ClientSQL: self._db.connect() return self._db + def _get_share(self) -> ShareMailFolder: + """Return the mail folder ACL sharing helper, lazily initialising it on first call.""" + if self._share is None: + self._share = ShareMailFolder(self._get_db()) + return self._share + + @staticmethod + def _folder_acl_key(account_id: str, folder_path: str) -> str: + """Build a stable sogo6_acl key for a folder from (account_id, folder_path). + + Mail folders have no opaque key like calendars/addressbooks - they are addressed by + their literal IMAP path, which can exceed sogo6_acl.key's 64-char cap for deep folder + hierarchies. Hashed so the key stays deterministic and within bounds regardless of + path length. + """ + return sha256(f"{account_id}\x00{folder_path}".encode("utf-8")).hexdigest() + def _get_user_conf(self, account_id: str) -> dict: user_mail_conf: dict = {} if account_id == cs.DEFAULT_IDENTITY_KEY_VALUE: @@ -344,91 +367,110 @@ def purge_all_folders(self, account_id: str, purge_data: dict[str, Any]) -> dict ) return {"mails_deleted": total_deleted} - def get_folder_share(self, account_id: str, folder_path: str) -> Iterator[tuple[str, dict[str, int]]]: + @staticmethod + def _imap_identifier(to_user: str) -> str: + """Map a sogo6_acl to_user to the IMAP ACL identifier. + + The "" pseudo to_user (cs.ANYONE_TO_USER, a SOGo/DB-only convention) maps to + the real IMAP special identifier "anyone" (RFC 4314); any other to_user is used as-is. """ - Yield the acl for a folder. - (identifier, {right1: 1, right2: 0, ...}) + return cs.USER_CLASS_ANY if to_user == cs.ANYONE_TO_USER else to_user + + def get_folder_share(self, account_id: str, folder_path: str) -> list[AclEntry]: + """Return all ACL entries (one per user) currently granted on a folder, read live from IMAP. + + IMAP is the source of truth for actual mail access. - :param account_id: _description_ + :param account_id: The account identifier :type account_id: str - :param folder_path: _description_ + :param folder_path: The name of the folder :type folder_path: str - :yield: _description_ - :rtype: Iterator[tuple[str, dict[str, int]]] + :return: List of ACL entries for the folder + :rtype: list[AclEntry] """ client = self._open_client_for(account_id) - # Get ACL from client (already converted to SOGo rights format) - yield from client.get_acl(folder_path) + key = self._folder_acl_key(account_id, folder_path) + entries: list[AclEntry] = [] + for identifier, imap_rights in client.get_acl_raw(folder_path): + to_user = cs.ANYONE_TO_USER if identifier == cs.USER_CLASS_ANY else identifier + if to_user == self.user.uid: + continue + entries.append(AclEntry( + resource_type=FOLDER_RESOURCE_TYPE, key=key, owner=self.user.uid, to_user=to_user, + rights=imap_permissions_to_rights(imap_rights), + )) + return entries + def patch_folder_share(self, account_id: str, folder_path: str, users: list[dict]) -> list[AclEntry]: + """Grant or update rights for one or several users on a folder, leaving other shares untouched. + Writes the live IMAP ACL (source of truth for mail access) and mirrors it into + sogo6_acl (type='folder'). - def share_folder(self, account_id:str, folder_path: str, share_data: list[dict[str, Any]]) -> Iterator[tuple[str, dict[str, int]]]: - """Share the specified folder with another user. - - :param folder_name: The name of the folder - :type folder_name: str - :param share_data: list of users with their rights configuration - :type share_data: list[dict[str, Any]] - :return: Share result data - :rtype: dict[str, Any] - :raises RequestException: If validation or manager operations fail + :param account_id: The account identifier + :type account_id: str + :param folder_path: The name of the folder + :type folder_path: str + :param users: list of ``{"uid": ..., "rights": {...}}`` entries to upsert + :type users: list[dict] + :return: List of ACL entries for the folder after the update + :rtype: list[AclEntry] + :raises RequestException: ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself """ client = self._open_client_for(account_id) + key = self._folder_acl_key(account_id, folder_path) + share = self._get_share() + for entry in users: + client.set_acl_raw(folder_path, self._imap_identifier(entry["uid"]), rights_to_imap_permissions(entry["rights"])) + share.add_permissions(entry["uid"], key, self.user.uid, entry["rights"]) + return share.get_permissions(key) - # Step 1: Get current ACL to know which users currently have permissions - current_acl = client.get_acl(folder_path) - current_users = {identifier for identifier, _ in current_acl} + def put_folder_share(self, account_id: str, folder_path: str, users: list[dict]) -> list[AclEntry]: + """Replace all existing shares on a folder with exactly the given users' rights. - # Step 2: Build list of users from the incoming share_data - new_users_dict: dict[str, dict[str, Any]] = {} # identifier -> rights_dict + Any user currently shared with but absent from ``users`` is revoked. Writes the live + IMAP ACL (source of truth for mail access) and mirrors it into sogo6_acl (type='folder'). - for user_entry in share_data: - # Extract user identifier (uid or c_email) - #TODO in fact, we need the user.login_mail_server - identifier = user_entry["c_email"] - rights_dict = user_entry.get("rights", {}) - - # Store rights dict directly (client will handle conversion) - new_users_dict[identifier] = rights_dict - - logger_mail_server.info("New users dict from share_data: %s", new_users_dict) - - # Step 3: Determine which users need to be removed (present in current but not in new) - users_to_remove = current_users - set(new_users_dict.keys()) - logger_mail_server.info("Users to be removed: %s", users_to_remove) - - # Step 4: Remove ACL for users not in the new list (except owner) - for user_to_remove in users_to_remove: - # Skip owner to avoid locking them out - if user_to_remove == self.user.login_mail_server: - continue - try: - client.delete_acl(folder_path, user_to_remove) - logger_mail_server.info("Removed ACL for folder '%s', user '%s'", folder_path, user_to_remove) - except RequestException as e: - logger_mail_server.warning("Failed to remove ACL for user '%s': %s", user_to_remove, e) - - # Step 5: Set/update ACL for users in the new list - for identifier, rights_dict in new_users_dict.items(): - # Check if any rights are set (at least one truthy value) - has_rights = any(rights_dict.values()) if rights_dict else False - - if has_rights: - # Set ACL for this user (client handles conversion) - try: - client.set_acl(folder_path, identifier, rights_dict) - logger_mail_server.info("Set ACL for folder '%s', user '%s', rights %s", folder_path, identifier, rights_dict) - except RequestException as e: - logger_mail_server.error("Failed to set ACL for user '%s': %s", identifier, e) - else: - # If no rights specified, delete the ACL entry - try: - client.delete_acl(folder_path, identifier) - logger_mail_server.info("Deleted ACL for folder '%s', user '%s' (no rights specified)", folder_path, identifier) - except RequestException as e: - logger_mail_server.warning("Failed to delete ACL for user '%s': %s", identifier, e) + :param account_id: The account identifier + :type account_id: str + :param folder_path: The name of the folder + :type folder_path: str + :param users: list of ``{"uid": ..., "rights": {...}}`` entries; becomes the full set of shares + :type users: list[dict] + :return: List of ACL entries for the folder after the replacement + :rtype: list[AclEntry] + :raises RequestException: ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself + """ + client = self._open_client_for(account_id) + key = self._folder_acl_key(account_id, folder_path) + share = self._get_share() + new_uids = {entry["uid"] for entry in users} + for existing in share.get_permissions(key): + if existing.to_user not in new_uids: + client.delete_acl(folder_path, self._imap_identifier(existing.to_user)) + share.remove_permissions(existing.to_user, key) + for entry in users: + client.set_acl_raw(folder_path, self._imap_identifier(entry["uid"]), rights_to_imap_permissions(entry["rights"])) + share.add_permissions(entry["uid"], key, self.user.uid, entry["rights"]) + return share.get_permissions(key) + + def post_folder_share(self, account_id: str, folder_path: str, users: list[dict]) -> list[AclEntry]: + """Grant sharing rights on a folder to one or several users, in addition to any existing share. + + Structurally identical to patch_folder_share (upsert the given users, leave others + untouched); kept as its own method so the API's PATCH/POST endpoints map 1:1 to ModuleMail. - yield from client.get_acl(folder_path) + :param account_id: The account identifier + :type account_id: str + :param folder_path: The name of the folder + :type folder_path: str + :param users: list of ``{"uid": ..., "rights": {...}}`` entries to upsert + :type users: list[dict] + :return: List of ACL entries for the folder after the update + :rtype: list[AclEntry] + :raises RequestException: ERROR_SHARE_CANNOT_SHARE_WITH_SELF if a target uid is the owner itself + """ + return self.patch_folder_share(account_id, folder_path, users) ############## #MAILS SERVER# @@ -654,7 +696,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 +1530,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 +1571,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 +1673,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 +1704,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 +1766,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..1bc712af 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,11 @@ 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) +ERROR_SHARE_PERMISSIONS_RIGHTS_MISMATCH = E("S001103", "Simplified 'permissions' And Advanced 'rights' Fields Are Inconsistent", HTTPStatus.BAD_REQUEST) + #the bugs ERROR_UNKOWN = E("S999999", "Undefined Error", HTTPStatus.INTERNAL_SERVER_ERROR) diff --git a/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/docs/developer/modules/ROOT/pages/calendar.adoc b/docs/developer/modules/ROOT/pages/calendar.adoc index 1348c2a5..49691a44 100644 --- a/docs/developer/modules/ROOT/pages/calendar.adoc +++ b/docs/developer/modules/ROOT/pages/calendar.adoc @@ -252,9 +252,9 @@ Centralizes all permission logic. Three responsibilities: `CalendarPermissions` has one `CalendarShareLevel` per visibility class (public, confidential, private) plus `can_create` and `can_delete` flags. Share levels are ordered: `NONE < VIEW_DATETIME < VIEW_ALL < RESPOND < MODIFY_IF_ORG < MODIFY`. -`MODIFY_IF_ORG` is the conditional level between `RESPOND` and `MODIFY`: the user gets `MODIFY` only on events they ORGANIZE, and behaves as `RESPOND` on everything else. Levels serialise by *name* (`modify_if_org` in the API), so the numbering is internal. +`MODIFY_IF_ORG` is the conditional level between `RESPOND` and `MODIFY`: the user gets `MODIFY` only on events they ORGANIZE, and behaves as `RESPOND` on everything else. In the API the levels serialise with the same strings as the sharing API (`none`, `view-date-time`, `view-all`, `respond-to`, `modify`); `MODIFY_IF_ORG` has no API string and is exposed as `respond-to`. -Each `CalCalendar` carries a transient `permissions` field populated at read time - the API response includes the user's permissions so the UI knows which actions to enable. +Each `CalCalendar` carries a transient `permissions` field populated at read time. The API exposes it as `rights` (`public`, `confidential`, `private`, `can_create_objects`, `can_erase_objects` - the same shape as the sogo6_acl rights blob) on `GET /calendars`, `GET /calendars/` and `GET /events/` (the latter resolves the rights on the event's calendar), so the UI knows which actions to enable. == Repositories diff --git a/playground/test_module_outgoing.py b/playground/test_module_outgoing.py index 3691877d..ea7ab0e4 100644 --- a/playground/test_module_outgoing.py +++ b/playground/test_module_outgoing.py @@ -16,7 +16,7 @@ SERVER = "192.168.69.34" PORT = 10125 -USERNAME = "tkeriven@snapshot.alinto.org" +USERNAME = "sogo-tests1@example.org" PASSWORD = "Banane2!" FROM_ADDR = USERNAME diff --git a/tests/test_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}} diff --git a/tests/test_interface/test_mail/test_InterfaceApiMailFolder.py b/tests/test_interface/test_mail/test_InterfaceApiMailFolder.py index 7273720a..ea1a9aef 100644 --- a/tests/test_interface/test_mail/test_InterfaceApiMailFolder.py +++ b/tests/test_interface/test_mail/test_InterfaceApiMailFolder.py @@ -3,6 +3,7 @@ Ces tests utilisent un fake ModuleMail pour tester la logique de l'interface. """ from app.interface.mail.InterfaceApiMailFolder import InterfaceApiMailFolder +from app.factory.share.RepositoryAcl import AclEntry from app.utils.exceptions import RequestException from app.utils import errors as err @@ -66,10 +67,8 @@ def __init__(self, user_conf, mail_module=None): class FakeModuleMail: """Fake ModuleMail for testing InterfaceApiMailFolder. - + Method signatures match ModuleMail (most methods receive account_id as first argument). - get_folder_share and share_folder return an iterator of (identifier, rights) tuples, - matching ModuleMail's Iterator[tuple[str, dict[str, int]]] return type. """ def __init__(self, user_conf=None): self.user_conf = user_conf @@ -83,7 +82,9 @@ def __init__(self, user_conf=None): self.get_one_folder_args = None self.purge_folder_mails_args = None self.get_folder_share_args = None - self.share_folder_args = None + self.patch_folder_share_args = None + self.put_folder_share_args = None + self.post_folder_share_args = None self.export_folder_mails_args = None # Configurable results @@ -94,9 +95,11 @@ def __init__(self, user_conf=None): self.update_folder_result = {"name": "UpdatedFolder"} self.get_one_folder_result = {"name": "INBOX", "path": "INBOX"} self.purge_folder_mails_result = {"mails_deleted": 10} - # Returns list of (identifier, rights) tuples (iterable, as ModuleMail yields them) + # Returns list of AclEntry, matching ModuleMail's DB+IMAP-backed ACL methods self.get_folder_share_result = [] - self.share_folder_result = [] + self.patch_folder_share_result = [] + self.put_folder_share_result = [] + self.post_folder_share_result = [] def get_folder_list(self, account_id): """Simulate getting folder list.""" @@ -138,14 +141,24 @@ def purge_folder_mails(self, account_id, folder_name, purge_data): return self.purge_folder_mails_result def get_folder_share(self, account_id, folder_path): - """Simulate getting folder share information. Returns iterable of (identifier, rights) tuples.""" + """Simulate reading a folder's live ACL entries from IMAP.""" self.get_folder_share_args = folder_path - return iter(self.get_folder_share_result) + return self.get_folder_share_result + + def patch_folder_share(self, account_id, folder_path, users): + """Simulate granting/updating a folder's ACL entries (IMAP + sogo6_acl), leaving others untouched.""" + self.patch_folder_share_args = (folder_path, users) + return self.patch_folder_share_result - def share_folder(self, account_id, folder_path, share_data): - """Simulate sharing a folder. Returns iterable of (identifier, rights) tuples.""" - self.share_folder_args = (folder_path, share_data) - return iter(self.share_folder_result) + def put_folder_share(self, account_id, folder_path, users): + """Simulate replacing all of a folder's ACL entries (IMAP + sogo6_acl).""" + self.put_folder_share_args = (folder_path, users) + return self.put_folder_share_result + + def post_folder_share(self, account_id, folder_path, users): + """Simulate granting a folder's ACL entries (IMAP + sogo6_acl), leaving others untouched.""" + self.post_folder_share_args = (folder_path, users) + return self.post_folder_share_result def export_folder_mails(self, folder_name): """Simulate exporting mails from a folder.""" @@ -388,87 +401,142 @@ def test_export_folder_mails_module_error(monkeypatch): assert status_code == 400 -# ========== Tests for get_folder_share ========== +# ========== Tests for folder share (GET/PATCH/PUT/POST) ========== +# ModuleMail is responsible for both the live IMAP ACL and its sogo6_acl (type "folder") +# mirror; the interface only resolves the request body then forwards to ModuleMail. def test_get_folder_share_success(monkeypatch): - """Test getting folder share information for a valid account (empty share list).""" + """Test getting share info for a folder, including the 'anyone' pseudo entry.""" fake_module = FakeModuleMail() - fake_module.get_folder_share_result = [] # No shares: yields nothing + fake_module.get_folder_share_result = [ + AclEntry( + resource_type="folder", key="k", owner="owner@example.com", to_user="", + rights={"user_can_view_folder": 1, "user_can_read_mails": 1, "user_can_write_mails": 0}, + ), + ] interface = make_interface(monkeypatch, fake_module) result, status_code = interface.get_folder_share(account_id=0, folder_path="INBOX") assert status_code == 200 - assert result["data"] == {} assert fake_module.get_folder_share_args == "INBOX" + users = result["data"] + anyone = next(u for u in users if u["uid"] == "anyone") + assert anyone["user_class"] == "anyone" + assert anyone["rights"] == {"userCanViewFolder": 1, "userCanReadMails": 1} -def test_get_folder_share_with_users(monkeypatch): - """Test getting folder share with existing users returns an 'anyone' entry.""" +def test_get_folder_share_module_error(monkeypatch): + """Test error handling when reading the folder's live IMAP ACL fails.""" fake_module = FakeModuleMail() - fake_module.get_folder_share_result = [ - ("anyone", {"read": 1, "write": 0}), - ] + fake_module.get_folder_share = lambda *args, **kwargs: (_ for _ in ()).throw(RequestException(error=err.ERROR_SHARE_NOT_FOUND)) interface = make_interface(monkeypatch, fake_module) result, status_code = interface.get_folder_share(account_id=0, folder_path="INBOX") + assert status_code == 404 + assert result["error_code"] == "S001100" + + +def test_patch_folder_share_success(monkeypatch): + """Test that 'permissions' codes resolve to a full 11-flag rights dict, unlisted rights denied.""" + fake_module = FakeModuleMail() + interface = make_interface(monkeypatch, fake_module) + + share_data = [{"uid": "bob@example.com", "c_email": "bob@example.com", "user_class": "user", "permissions": ["l", "r"]}] + result, status_code = interface.patch_folder_share(account_id=0, folder_path="INBOX", share_data=share_data) + assert status_code == 200 - assert "anyone" in result["data"] - assert result["data"]["anyone"]["rights"] == {"read": 1, "write": 0} + folder_path, users = fake_module.patch_folder_share_args + assert folder_path == "INBOX" + assert users == [{ + "uid": "bob@example.com", + "rights": { + "user_can_view_folder": 1, + "user_can_read_mails": 1, + "user_can_mark_mails_read": 0, + "user_can_write_mails": 0, + "user_can_insert_mails": 0, + "user_can_post_mails": 0, + "user_can_create_subfolders": 0, + "user_can_remove_folder": 0, + "user_can_erase_mails": 0, + "user_can_expunge_folder": 0, + "user_is_administrator": 0, + }, + }] + + +def test_patch_folder_share_anyone_user_class(monkeypatch): + """Test that user_class 'anyone' resolves to the ANYONE_TO_USER pseudo-uid.""" + fake_module = FakeModuleMail() + interface = make_interface(monkeypatch, fake_module) + share_data = [{"user_class": "anyone", "permissions": ["l", "r"]}] + interface.patch_folder_share(account_id=0, folder_path="INBOX", share_data=share_data) -def test_get_folder_share_module_error(monkeypatch): - """Test error handling when getting folder share fails.""" + _, users = fake_module.patch_folder_share_args + assert users[0]["uid"] == "" + + +def test_patch_folder_share_permissions_rights_mismatch(monkeypatch): + """Test that conflicting 'permissions' and 'rights' fields return the mismatch error.""" fake_module = FakeModuleMail() - fake_module.get_folder_share = lambda *args: (_ for _ in ()).throw(RequestException("Cannot get share", err.ERROR_VALIDATION_ERROR)) interface = make_interface(monkeypatch, fake_module) - result, status_code = interface.get_folder_share(account_id=0, folder_path="INBOX") + share_data = [{ + "uid": "bob@example.com", "c_email": "bob@example.com", "user_class": "user", + "permissions": ["l"], "rights": {"user_can_view_folder": 0}, + }] + result, status_code = interface.patch_folder_share(account_id=0, folder_path="INBOX", share_data=share_data) - assert result["error_code"] == "S000300" assert status_code == 400 + assert result["error_code"] == "S001103" -# ========== Tests for share_folder ========== - -def test_share_folder_success(monkeypatch): - """Test sharing a folder for a valid account (empty result).""" +def test_patch_folder_share_module_error(monkeypatch): + """Test error handling when the module rejects the share (e.g. sharing with oneself).""" fake_module = FakeModuleMail() - fake_module.share_folder_result = [] # No shares returned after update + fake_module.patch_folder_share = lambda *args, **kwargs: (_ for _ in ()).throw(RequestException(error=err.ERROR_SHARE_CANNOT_SHARE_WITH_SELF)) interface = make_interface(monkeypatch, fake_module) - share_data = [{"email": "user2@example.com", "read": True, "write": True}] - result, status_code = interface.share_folder(account_id=0, folder_path="INBOX", share_data=share_data) + result, status_code = interface.patch_folder_share( + account_id=0, folder_path="INBOX", + share_data=[{"uid": "bob@example.com", "c_email": "bob@example.com", "user_class": "user", "permissions": ["l"]}], + ) - assert status_code == 200 - assert result["data"] == {} - assert fake_module.share_folder_args == ("INBOX", share_data) + assert status_code == 400 + assert result["error_code"] == "S001102" -def test_share_folder_with_anyone(monkeypatch): - """Test sharing a folder returns an 'anyone' ACL entry.""" +def test_put_folder_share_success(monkeypatch): + """Test that PUT resolves rights and forwards them to put_folder_share.""" fake_module = FakeModuleMail() - fake_module.share_folder_result = [ - ("anyone", {"read": 1, "write": 1}), - ] interface = make_interface(monkeypatch, fake_module) - share_data = [{"email": "anyone", "read": True, "write": True}] - result, status_code = interface.share_folder(account_id=0, folder_path="INBOX", share_data=share_data) + share_data = [{"uid": "bob@example.com", "c_email": "bob@example.com", "user_class": "user", "permissions": ["a"]}] + result, status_code = interface.put_folder_share(account_id=0, folder_path="INBOX", share_data=share_data) assert status_code == 200 - assert "anyone" in result["data"] - assert result["data"]["anyone"]["rights"] == {"read": 1, "write": 1} + folder_path, users = fake_module.put_folder_share_args + assert folder_path == "INBOX" + assert users[0]["uid"] == "bob@example.com" + assert users[0]["rights"]["user_is_administrator"] == 1 -def test_share_folder_module_error(monkeypatch): - """Test error handling when sharing folder fails.""" +def test_post_folder_share_success(monkeypatch): + """Test that POST resolves rights from the 'rights' field and forwards them to post_folder_share.""" fake_module = FakeModuleMail() - fake_module.share_folder = lambda *args: (_ for _ in ()).throw(RequestException("Cannot share", err.ERROR_VALIDATION_ERROR)) interface = make_interface(monkeypatch, fake_module) - result, status_code = interface.share_folder(account_id=0, folder_path="INBOX", share_data=[]) + share_data = [{ + "uid": "bob@example.com", "c_email": "bob@example.com", "user_class": "user", + "rights": {"user_can_view_folder": 1, "user_can_read_mails": 1}, + }] + result, status_code = interface.post_folder_share(account_id=0, folder_path="INBOX", share_data=share_data) - assert result["error_code"] == "S000300" - assert status_code == 400 + assert status_code == 200 + folder_path, users = fake_module.post_folder_share_args + assert folder_path == "INBOX" + assert users[0]["rights"]["user_can_view_folder"] == 1 + assert users[0]["rights"]["user_can_write_mails"] == 0 diff --git a/tests/test_manager/test_mail/test_clientImap.py b/tests/test_manager/test_mail/test_clientImap.py index d0680066..c71cfb0c 100644 --- a/tests/test_manager/test_mail/test_clientImap.py +++ b/tests/test_manager/test_mail/test_clientImap.py @@ -817,6 +817,45 @@ def test_delete_acl_not_authenticated_raises(self): with pytest.raises(BugException): client.delete_acl("INBOX", "user") + def test_get_acl_raw_yields_raw_pairs(self): + fake_conn = FakeIMAPConnection() + fake_conn.getacl_response = ("OK", [b"INBOX user1 lrswipkxtea user2 lr"]) + client = authenticated_client(fake_conn) + + acl_list = list(client.get_acl_raw("INBOX")) + assert acl_list == [("user1", "lrswipkxtea"), ("user2", "lr")] + + def test_get_acl_raw_not_authenticated_raises(self): + client = make_client() + client.connection = None + with pytest.raises(BugException): + list(client.get_acl_raw("INBOX")) + + def test_get_acl_raw_failure_raises_request_exception(self): + fake_conn = FakeIMAPConnection() + fake_conn.getacl_response = ("NO", [b"Mailbox doesn't exist"]) + client = authenticated_client(fake_conn) + with pytest.raises(RequestException): + list(client.get_acl_raw("Ghost")) + + def test_set_acl_raw_success(self): + fake_conn = FakeIMAPConnection() + client = authenticated_client(fake_conn) + client.set_acl_raw("INBOX", "anyone", "lr") + + def test_set_acl_raw_not_authenticated_raises(self): + client = make_client() + client.connection = None + with pytest.raises(BugException): + client.set_acl_raw("INBOX", "user", "lr") + + def test_set_acl_raw_failure_raises_request_exception(self): + fake_conn = FakeIMAPConnection() + fake_conn.setacl_response = ("NO", [b"Mailbox doesn't exist"]) + client = authenticated_client(fake_conn) + with pytest.raises(RequestException): + client.set_acl_raw("Ghost", "user", "lr") + # =========================================================================== # Tests: uid_copy diff --git a/tests/test_module/test_mail/test_moduleMail.py b/tests/test_module/test_mail/test_moduleMail.py index d7bbbcd5..8d629973 100644 --- a/tests/test_module/test_mail/test_moduleMail.py +++ b/tests/test_module/test_mail/test_moduleMail.py @@ -6,6 +6,7 @@ from io import BytesIO from unittest.mock import MagicMock from app.module.mail.ModuleMail import ModuleMail +from app.factory.share.RepositoryAcl import AclEntry from app.utils.exceptions import RequestException from app.utils.api.paginate_sort_filter import CollectionPaginateArgs @@ -36,6 +37,7 @@ def __init__(self): self.fetch_mail_result = None # set per test self.fetch_mail_raw_result = 'Subject: Test\r\n\r\nBody' self.get_acl_result = [('user1@example.com', {'userCanViewFolder': 1})] + self.get_acl_raw_result = [('user1@example.com', 'lr')] # Call tracking self.create_folder_calls = [] @@ -46,6 +48,7 @@ def __init__(self): self.delete_mails_by_uid_calls = [] self.set_acl_calls = [] self.delete_acl_calls = [] + self.set_acl_raw_calls = [] # ---- folder methods ---- @@ -122,6 +125,14 @@ def delete_acl(self, folder_name, identifier): """Delete ACL for a folder.""" self.delete_acl_calls.append((folder_name, identifier)) + def get_acl_raw(self, folder_path): + """Get the raw IMAP ACL for a folder (no SOGo rights conversion).""" + return self.get_acl_raw_result + + def set_acl_raw(self, folder_path, identifier, imap_rights): + """Set the raw IMAP ACL rights string for identifier on folder (no SOGo rights conversion).""" + self.set_acl_raw_calls.append((folder_path, identifier, imap_rights)) + def get_mail_uids_before_date(self, mailbox, before_date=None, exclude_deleted=True): """Get mail UIDs in a mailbox before a certain date.""" if before_date: @@ -196,6 +207,35 @@ def _make_module(monkeypatch, fake_client=None): return module, fake_client +class FakeShare: + """Fake ShareMailFolder for testing ModuleMail's sogo6_acl (type='folder') mirror.""" + + def __init__(self): + self.entries = {} # (key, to_user) -> AclEntry + self.add_permissions_calls = [] + self.remove_permissions_calls = [] + + def get_permissions(self, key): + return [entry for (stored_key, _), entry in self.entries.items() if stored_key == key] + + def add_permissions(self, for_user, on_key, owner, rights): + self.add_permissions_calls.append((for_user, on_key, owner, rights)) + self.entries[(on_key, for_user)] = AclEntry( + resource_type="folder", key=on_key, owner=owner, to_user=for_user, rights=rights, + ) + + def remove_permissions(self, for_user, on_key): + self.remove_permissions_calls.append((for_user, on_key)) + self.entries.pop((on_key, for_user), None) + + +def _make_share(monkeypatch, module): + """Patch module._get_share to return a fresh FakeShare, and return it for assertions.""" + fake_share = FakeShare() + monkeypatch.setattr(module, '_get_share', lambda: fake_share) + return fake_share + + # ========== Tests for initialization ========== def test_module_init_success(): @@ -470,45 +510,82 @@ def test_get_mail_raw_success(monkeypatch): # ========== Tests for get_folder_share ========== def test_get_folder_share_success(monkeypatch): - """Test getting folder share information (yields tuples).""" + """Test getting folder share information reads the live IMAP ACL (source of truth).""" module, fake_client = _make_module(monkeypatch) - fake_client.get_acl_result = [ - ('user1@example.com', {'userCanViewFolder': 1, 'userCanReadMails': 1}), - ('anyone', {'userCanViewFolder': 1}) + fake_client.get_acl_raw_result = [ + ('user1@example.com', 'lr'), + ('anyone', 'l'), ] - result = list(module.get_folder_share(ACCOUNT_ID, "INBOX")) - identifiers = [item[0] for item in result] - assert 'user1@example.com' in identifiers - assert 'anyone' in identifiers + result = module.get_folder_share(ACCOUNT_ID, "INBOX") + + to_users = {entry.to_user for entry in result} + assert 'user1@example.com' in to_users + assert '' in to_users # IMAP 'anyone' identifier maps to the sogo6_acl pseudo to_user + user1 = next(entry for entry in result if entry.to_user == 'user1@example.com') + assert user1.rights['user_can_view_folder'] == 1 + assert user1.rights['user_can_read_mails'] == 1 -# ========== Tests for share_folder ========== +# ========== Tests for patch_folder_share / put_folder_share / post_folder_share ========== -def test_share_folder_success(monkeypatch): - """Test sharing a folder with users.""" +def test_patch_folder_share_success(monkeypatch): + """Test patching folder share sets the live IMAP ACL and mirrors it into sogo6_acl.""" module, fake_client = _make_module(monkeypatch) - module.user.login_mail_server = 'owner@example.com' - fake_client.get_acl_result = [] + fake_share = _make_share(monkeypatch, module) - def get_acl_after_share(folder_path): - if fake_client.set_acl_calls: - return [('user1@example.com', {'userCanViewFolder': 1, 'userCanReadMails': 1})] - return [] + users = [{"uid": "user1@example.com", "rights": {"user_can_view_folder": 1, "user_can_read_mails": 1}}] + result = module.patch_folder_share(ACCOUNT_ID, "INBOX", users) - fake_client.get_acl = get_acl_after_share + assert len(fake_client.set_acl_raw_calls) == 1 + folder_path, identifier, imap_rights = fake_client.set_acl_raw_calls[0] + assert folder_path == "INBOX" + assert identifier == "user1@example.com" + assert set(imap_rights) == {"l", "r"} + assert len(fake_share.add_permissions_calls) == 1 + assert result[0].to_user == "user1@example.com" + + +def test_patch_folder_share_anyone_maps_to_imap_identifier(monkeypatch): + """Test that the '' pseudo to_user maps to the IMAP special identifier 'anyone'.""" + module, fake_client = _make_module(monkeypatch) + _make_share(monkeypatch, module) + + users = [{"uid": "", "rights": {"user_can_view_folder": 1}}] + module.patch_folder_share(ACCOUNT_ID, "INBOX", users) + + assert fake_client.set_acl_raw_calls[0][1] == "anyone" - share_data = [ - { - "c_email": "user1@example.com", - "rights": {"userCanViewFolder": 1, "userCanReadMails": 1} - } - ] - result = list(module.share_folder(ACCOUNT_ID, "INBOX", share_data)) - assert len(fake_client.set_acl_calls) >= 1 - # share_folder yields (identifier, rights) tuples - assert any(item[0] == 'user1@example.com' for item in result) +def test_put_folder_share_revokes_missing_users(monkeypatch): + """Test that PUT revokes users no longer in the list, on both IMAP and sogo6_acl.""" + module, fake_client = _make_module(monkeypatch) + fake_share = _make_share(monkeypatch, module) + key = module._folder_acl_key(ACCOUNT_ID, "INBOX") + fake_share.entries[(key, "user2@example.com")] = AclEntry( + resource_type="folder", key=key, owner=module.user.uid, to_user="user2@example.com", + rights={"user_can_view_folder": 1}, + ) + + users = [{"uid": "user1@example.com", "rights": {"user_can_view_folder": 1}}] + result = module.put_folder_share(ACCOUNT_ID, "INBOX", users) + + assert fake_client.delete_acl_calls == [("INBOX", "user2@example.com")] + assert fake_client.set_acl_raw_calls[0][1] == "user1@example.com" + to_users = {entry.to_user for entry in result} + assert to_users == {"user1@example.com"} + + +def test_post_folder_share_behaves_like_patch(monkeypatch): + """Test that POST upserts the given users without touching others (same as PATCH).""" + module, fake_client = _make_module(monkeypatch) + _make_share(monkeypatch, module) + + users = [{"uid": "user1@example.com", "rights": {"user_can_view_folder": 1}}] + result = module.post_folder_share(ACCOUNT_ID, "INBOX", users) + + assert len(fake_client.set_acl_raw_calls) == 1 + assert result[0].to_user == "user1@example.com" # ========== Tests for perform_mail_action ========== @@ -1119,74 +1196,22 @@ def fetch_all_without_content(mailbox, number_of_mails, offset=0): assert 'contents' not in result[0] -# ========== Additional Tests for share_folder with removal ========== +# ========== Additional tests for put_folder_share with multiple users ========== -def test_share_folder_with_user_removal(monkeypatch): - """Test sharing a folder and removing a previously shared user.""" +def test_put_folder_share_with_multiple_users(monkeypatch): + """Test replacing a folder's share with multiple users sets IMAP ACL for each.""" module, fake_client = _make_module(monkeypatch) - module.user.login_mail_server = 'owner@example.com' - - # Initial ACL with two users - def get_acl_mock(folder_path): - if fake_client.set_acl_calls: - return [ - ('user1@example.com', {'userCanViewFolder': 1, 'userCanReadMails': 1}), - ('user2@example.com', {'userCanViewFolder': 1}) - ] - return [ - ('user1@example.com', {'userCanViewFolder': 1, 'userCanReadMails': 1}), - ('user2@example.com', {'userCanViewFolder': 1}) - ] - - fake_client.get_acl = get_acl_mock + _make_share(monkeypatch, module) - # Only share with user1, removing user2 - share_data = [ - { - "c_email": "user1@example.com", - "rights": {"userCanViewFolder": 1, "userCanReadMails": 1} - } + users = [ + {"uid": "user1@example.com", "rights": {"user_can_view_folder": 1, "user_can_read_mails": 1}}, + {"uid": "user2@example.com", "rights": {"user_can_view_folder": 1}}, ] - result = list(module.share_folder(ACCOUNT_ID, "INBOX", share_data)) - - # Should have called delete_acl for user2 - assert len(fake_client.delete_acl_calls) >= 1 - # Should have updated user1 - assert len(fake_client.set_acl_calls) >= 1 - + result = module.put_folder_share(ACCOUNT_ID, "INBOX", users) -def test_share_folder_with_multiple_users(monkeypatch): - """Test sharing a folder with multiple users.""" - module, fake_client = _make_module(monkeypatch) - module.user.login_mail_server = 'owner@example.com' - fake_client.get_acl_result = [] - - def get_acl_after_share(folder_path): - if fake_client.set_acl_calls: - return [ - ('user1@example.com', {'userCanViewFolder': 1, 'userCanReadMails': 1}), - ('user2@example.com', {'userCanViewFolder': 1}) - ] - return [] - - fake_client.get_acl = get_acl_after_share - - share_data = [ - { - "c_email": "user1@example.com", - "rights": {"userCanViewFolder": 1, "userCanReadMails": 1} - }, - { - "c_email": "user2@example.com", - "rights": {"userCanViewFolder": 1} - } - ] - - result = list(module.share_folder(ACCOUNT_ID, "INBOX", share_data)) - - # Should have called set_acl for both users - assert len(fake_client.set_acl_calls) == 2 - identifiers = [call[1] for call in fake_client.set_acl_calls] + assert len(fake_client.set_acl_raw_calls) == 2 + identifiers = [call[1] for call in fake_client.set_acl_raw_calls] assert 'user1@example.com' in identifiers assert 'user2@example.com' in identifiers + assert {entry.to_user for entry in result} == {'user1@example.com', 'user2@example.com'}