Skip to content

Commit f9a5f0c

Browse files
committed
OP#2801 : add addressbooks share APIs
1 parent cbdd8d1 commit f9a5f0c

11 files changed

Lines changed: 556 additions & 18 deletions

File tree

app/api/v1/contact/ApiContact.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
from flask.typing import ResponseReturnValue
88
from flask_smorest import Blueprint
99

10+
from app.config.settings.DomainSettings import UserModuleSettings
1011
from app.interface.contact.InterfaceApiContactContact import InterfaceApiContactContact
1112
from app.module.contact.ContactConst import IMPORT_MAX_BYTES
1213
from app.module.contact.source.ContactSourceDb import LIST_SORTABLE_COLUMNS, SORTABLE_COLUMNS
1314
from app.utils.api.ApiBaseResponse import create_api_base_response
1415
from app.utils.api.paginate_sort_filter import collection_paginate, CustomPaginateResponse
15-
from app.utils.errors import ERROR_CONTACT_IMPORT_NO_FILE, ERROR_CONTACT_IMPORT_TOO_LARGE
16+
from app.utils.errors import ERROR_CONTACT_IMPORT_NO_FILE, ERROR_CONTACT_IMPORT_TOO_LARGE, ERROR_CONTACT_SHARING_DISABLED
1617
from app.utils.logger.logger import logger_api
1718
from .schemas.addressbook import (
1819
AddressBookCreateSchema,
@@ -22,6 +23,10 @@
2223
ContactImportQueryArgsSchema,
2324
ContactImportUploadSchema,
2425
ContactJobResponseSchema,
26+
ContactSharePatchSchema,
27+
ContactSharePutSchema,
28+
ContactSharePostSchema,
29+
ContactShareResponseSchema,
2530
)
2631
from .schemas.contact import (
2732
ContactCreateSchema,
@@ -67,7 +72,14 @@
6772

6873

6974
@blp.before_request
70-
def init_contact_config() -> None: # pylint: disable=missing-function-docstring
75+
def init_contact_config() -> ResponseReturnValue | None: # pylint: disable=missing-function-docstring
76+
if request.path.endswith("/share"):
77+
user_domain_settings: dict = g.user_domain_settings
78+
user_module_settings: dict = user_domain_settings.get(UserModuleSettings.subparent, {})
79+
if "contact" in user_module_settings.get("SOGO_D_FOLDER_DISABLE_SHARING", []):
80+
logger_api.debug("Access denied for %s: contact sharing is disabled", request.path)
81+
return create_api_base_response(None, ERROR_CONTACT_SHARING_DISABLED)
82+
7183
g.inter = InterfaceApiContactContact(
7284
process_setting=g.process_settings,
7385
user_domain_settings=g.user_domain_settings,
@@ -122,6 +134,49 @@ def delete(self, key: str) -> ResponseReturnValue:
122134
return interface.delete_addressbook(key)
123135

124136

137+
@blp.route("/addressbooks/<string:key>/share")
138+
class ApiAddressBookShare(MethodView):
139+
"""API to manage address book sharing and user permissions."""
140+
141+
@blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example())
142+
def get(self, key: str) -> ResponseReturnValue:
143+
"""Get all user permissions for an address book."""
144+
logger_api.debug("GET /addressbooks/%s/share user=%s", key, g.user.uid)
145+
interface: InterfaceApiContactContact = g.inter
146+
return interface.get_addressbook_share(key)
147+
148+
@blp.arguments(ContactSharePatchSchema(many=True), example=ContactSharePatchSchema.example()) # type: ignore [arg-type]
149+
@blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example())
150+
def patch(self, body: list[dict], key: str) -> ResponseReturnValue:
151+
"""Partially update user permissions for an address book.
152+
153+
Only the users specified in the request body are modified.
154+
Other existing permissions remain unchanged.
155+
"""
156+
logger_api.debug("PATCH /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body)
157+
interface: InterfaceApiContactContact = g.inter
158+
return interface.patch_addressbook_share(key, body)
159+
160+
@blp.arguments(ContactSharePutSchema(many=True), example=ContactSharePutSchema.example()) # type: ignore [arg-type]
161+
@blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example())
162+
def put(self, body: list[dict], key: str) -> ResponseReturnValue:
163+
"""Replace all user permissions for an address book.
164+
165+
All existing permissions are replaced by the users specified in the request body.
166+
"""
167+
logger_api.debug("PUT /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body)
168+
interface: InterfaceApiContactContact = g.inter
169+
return interface.put_addressbook_share(key, body)
170+
171+
@blp.arguments(ContactSharePostSchema(many=True), example=ContactSharePostSchema.example()) # type: ignore [arg-type]
172+
@blp.response(200, ContactShareResponseSchema, example=ContactShareResponseSchema.example())
173+
def post(self, body: list[dict], key: str) -> ResponseReturnValue:
174+
"""Grant full permissions to one or several users."""
175+
logger_api.debug("POST /addressbooks/%s/share user=%s body=%s", key, g.user.uid, body)
176+
interface: InterfaceApiContactContact = g.inter
177+
return interface.post_addressbook_share(key, body)
178+
179+
125180
@blp.route("/addressbooks/<string:key>/contacts")
126181
class ApiAddressBookContactList(MethodView):
127182
"""API to list (paginated) and create contacts within one address book."""

app/api/v1/contact/schemas/addressbook.py

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3-
from marshmallow import Schema, fields, validate
3+
from typing import Any
4+
5+
from marshmallow import Schema, fields, validate, validates_schema, ValidationError
46

57
from app.utils.api.ApiBaseResponse import ApiBaseResponse
68

@@ -83,3 +85,164 @@ class ContactImportUploadSchema(Schema):
8385
metadata={"type": "string", "format": "binary",
8486
"description": "The JSON (.json), vCard (.vcf) or LDIF (.ldif) file to import."},
8587
)
88+
89+
90+
class ContactShareRightsSchema(Schema):
91+
"""Permission rights for an address book share."""
92+
93+
can_view = fields.Boolean(required=True, metadata={"description": "Can view contacts and lists", "example": True})
94+
can_create_objects = fields.Boolean(required=True, metadata={"description": "Can create contacts and lists", "example": True})
95+
can_edit_objects = fields.Boolean(required=True, metadata={"description": "Can edit contacts and lists", "example": True})
96+
can_erase_objects = fields.Boolean(required=True, metadata={"description": "Can delete contacts and lists", "example": False})
97+
98+
99+
class ContactShareUserSchema(Schema):
100+
"""User permission entry in address book sharing.
101+
102+
``c_email`` and ``uid`` are required unless ``user_class`` is ``"anyone"``, in which case
103+
they are ignored (the share applies to any authenticated user, not a specific one).
104+
"""
105+
106+
c_email = fields.String(required=False, allow_none=True, metadata={"description": "User email address", "example": "jdoe@example.org"})
107+
uid = fields.String(required=False, allow_none=True, metadata={"description": "User UID", "example": "jdoe"})
108+
user_class = fields.String(
109+
required=True,
110+
validate=validate.OneOf(["user", "anyone"]),
111+
)
112+
rights = fields.Nested(ContactShareRightsSchema, required=True, metadata={"description": "Permission rights for this user"})
113+
114+
@validates_schema
115+
def validate_user_identity(self, data: dict[str, Any], **kwargs: Any) -> None: # pylint: disable=unused-argument
116+
"""Require c_email and uid unless user_class is 'anyone'."""
117+
if data.get("user_class") == "anyone":
118+
return
119+
errors: dict[str, list[str]] = {}
120+
if not data.get("c_email"):
121+
errors["c_email"] = ["Missing data for required field."]
122+
if not data.get("uid"):
123+
errors["uid"] = ["Missing data for required field."]
124+
if errors:
125+
raise ValidationError(errors)
126+
127+
128+
class ContactSharePatchSchema(ContactShareUserSchema):
129+
"""Request body item for PATCH /addressbooks/{key}/share - partial update of user permissions.
130+
131+
The endpoint expects a JSON list of these objects (use with ``many=True``).
132+
Only the users specified in the request are modified. Other existing permissions remain unchanged.
133+
"""
134+
135+
class Meta:
136+
ordered = True
137+
138+
@staticmethod
139+
def example() -> list[dict[str, Any]]:
140+
"""Example data for Swagger documentation."""
141+
return [
142+
{
143+
"c_email": "jdoe@example.org",
144+
"uid": "jdoe",
145+
"user_class": "user",
146+
"rights": {
147+
"can_view": True,
148+
"can_create_objects": True,
149+
"can_edit_objects": True,
150+
"can_erase_objects": False
151+
}
152+
}
153+
]
154+
155+
156+
class ContactSharePutSchema(ContactShareUserSchema):
157+
"""Request body item for PUT /addressbooks/{key}/share - replace all user permissions.
158+
159+
The endpoint expects a JSON list of these objects (use with ``many=True``).
160+
All existing permissions are replaced by the users specified in the request.
161+
"""
162+
163+
class Meta:
164+
ordered = True
165+
166+
@staticmethod
167+
def example() -> list[dict[str, Any]]:
168+
"""Example data for Swagger documentation."""
169+
return [
170+
{
171+
"c_email": "jdoe@example.org",
172+
"uid": "jdoe",
173+
"user_class": "user",
174+
"rights": {
175+
"can_view": True,
176+
"can_create_objects": True,
177+
"can_edit_objects": True,
178+
"can_erase_objects": False
179+
}
180+
},
181+
{
182+
"c_email": "alice@example.org",
183+
"uid": "alice",
184+
"user_class": "user",
185+
"rights": {
186+
"can_view": True,
187+
"can_create_objects": True,
188+
"can_edit_objects": True,
189+
"can_erase_objects": True
190+
}
191+
}
192+
]
193+
194+
195+
class ContactSharePostSchema(ContactShareUserSchema):
196+
"""Request body item for POST /addressbooks/{key}/share - grant full permissions to users.
197+
198+
The endpoint expects a JSON list of these objects (use with ``many=True``).
199+
Grants full view/create/edit/erase rights to the specified users, regardless of the rights
200+
carried in the request body.
201+
"""
202+
203+
class Meta:
204+
ordered = True
205+
206+
@staticmethod
207+
def example() -> list[dict[str, Any]]:
208+
"""Example data for Swagger documentation."""
209+
return [
210+
{
211+
"c_email": "jdoe@example.org",
212+
"uid": "jdoe",
213+
"user_class": "user",
214+
"rights": {
215+
"can_view": True,
216+
"can_create_objects": True,
217+
"can_edit_objects": True,
218+
"can_erase_objects": True
219+
}
220+
}
221+
]
222+
223+
224+
class ContactShareResponseSchema(ApiBaseResponse):
225+
"""Response schema for address book sharing endpoints. ``data`` is a plain list of users."""
226+
227+
data = fields.List(fields.Nested(ContactShareUserSchema), allow_none=True)
228+
229+
@staticmethod
230+
def example() -> dict[str, Any]:
231+
"""Example full envelope for Swagger documentation."""
232+
return {
233+
"data": [
234+
{
235+
"c_email": "jdoe@example.org",
236+
"uid": "jdoe",
237+
"user_class": "user",
238+
"rights": {
239+
"can_view": True,
240+
"can_create_objects": True,
241+
"can_edit_objects": True,
242+
"can_erase_objects": False
243+
}
244+
}
245+
],
246+
"error_code": "S000000",
247+
"error_msg": "No Error"
248+
}

app/factory/share/shareContact.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from app.factory.share.share import Share
6+
from app.module.contact.model.enums.ContactShareLevel import ContactShareLevel
7+
from app.utils import constants as cs
8+
from app.utils.strings import get_domain_from_mail
9+
10+
if TYPE_CHECKING:
11+
from app.factory.share.RepositoryAcl import AclEntry
12+
13+
# Discriminant stored in sogo6_acl.type for address book shares.
14+
CONTACT_RESOURCE_TYPE: str = "addressbook"
15+
16+
# Rights blob granted by POST /addressbooks/{key}/share (full access, per the endpoint's contract).
17+
FULL_MODIFY_RIGHTS: dict = {
18+
"can_view": True,
19+
"can_create_objects": True,
20+
"can_edit_objects": True,
21+
"can_erase_objects": True,
22+
}
23+
24+
25+
class ShareContact(Share):
26+
"""Sharing for address books, backed by sogo6_acl (type='addressbook').
27+
28+
The rights blob stored per (addressbook key, to_user) matches the API's
29+
ContactShareRightsSchema: ``{"can_view": bool, "can_create_objects": bool,
30+
"can_edit_objects": bool, "can_erase_objects": bool}``.
31+
32+
``rights_needed`` passed to ``check_permissions`` is the name of the right to check
33+
(e.g. "can_view", "can_edit_objects").
34+
"""
35+
36+
resource_type: str = CONTACT_RESOURCE_TYPE
37+
38+
def get_user_or_anyone(self, for_user_uid: str, owner_uid: str, on_key: str) -> AclEntry | None:
39+
"""Resolve the ACL entry granting for_user_uid access to on_key.
40+
41+
Priority: an entry addressed specifically to for_user_uid; failing that, the "anyone"
42+
pseudo entry (``cs.ANYONE_TO_USER``, "<default>") - but only when for_user_uid and
43+
owner_uid belong to the same mail domain, since an "anyone" share only ever means
44+
"anyone in the owner's domain".
45+
"""
46+
entry: AclEntry | None = self.get_entry(for_user_uid, on_key)
47+
if entry is not None:
48+
return entry
49+
user_domain: str | None = get_domain_from_mail(for_user_uid)
50+
owner_domain: str | None = get_domain_from_mail(owner_uid)
51+
if not user_domain or user_domain != owner_domain:
52+
return None
53+
return self.get_entry(cs.ANYONE_TO_USER, on_key)
54+
55+
@staticmethod
56+
def to_share_level(rights: dict) -> ContactShareLevel | None:
57+
"""Convert a stored rights blob into a ContactShareLevel, for ContactAclEngine.
58+
59+
Any write flag (create/edit/erase) grants MODIFY (which also satisfies a VIEW check);
60+
otherwise can_view alone grants VIEW; a rights blob granting nothing at all denies.
61+
"""
62+
if rights.get("can_create_objects") or rights.get("can_edit_objects") or rights.get("can_erase_objects"):
63+
return ContactShareLevel.MODIFY
64+
if rights.get("can_view"):
65+
return ContactShareLevel.VIEW
66+
return None
67+
68+
def _rights_satisfy(self, rights: dict, rights_needed: str) -> bool:
69+
return bool(rights.get(rights_needed, False))

app/interface/calendar/InterfaceApiCalendarCalendar.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,7 @@ def _grant_folder_subs_keys(self, target_uids: Iterable[str], key: str) -> None:
748748
"""
749749
for target_uid in target_uids:
750750
if target_uid == cs.ANYONE_TO_USER:
751-
continue
751+
continue # The "anyone" pseudo-user has no real folders to update, so skip it.
752752
self._user_module.add_folder_key(target_uid, "CALENDAR", key, owner_key="SUBS")
753753

754754
def _serialize_share_entries(self, entries: list[AclEntry]) -> list[dict[str, Any]]:

0 commit comments

Comments
 (0)