dspace-api-toolkit 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,486 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Authorization checks for DSpace API (DSpace 7+ / DSpace-CRIS): "may this
4
+ user do X on object Y?" - the same checks the Angular UI uses to show or
5
+ hide buttons, so scripts can test permissions before acting.
6
+
7
+ GET /authz/authorizations/{id}[/eperson|/feature|/object]
8
+ GET /authz/authorizations/search/object?uri=&eperson=&feature=
9
+ GET /authz/authorizations/search/objects?uuid=..&type=&eperson=&feature=.. (batch)
10
+ GET /authz/features[/{id}] (admin only)
11
+ GET /authz/features/search/resourcetype?type= (admin only)
12
+
13
+ auth = client.authorizations
14
+ auth.can(item, Feature.CAN_EDIT_METADATA) # current user
15
+ auth.can(collection, Feature.ADMINISTRATOR_OF, eperson=jane) # someone else (admin, or jane herself)
16
+ auth.features_on(item) # {"canEditMetadata", "withdrawItem", ...}
17
+ auth.check(item, [Feature.WITHDRAW_ITEM, Feature.CAN_DELETE]) # {"withdrawItem": True, "canDelete": False}
18
+ auth.require(item, Feature.CAN_MOVE) # raises AuthorizationError if not
19
+ auth.can_many(items, Feature.CAN_EDIT_METADATA) # {uuid: bool}, one request per type
20
+ auth.is_site_admin()
21
+
22
+ Objects can be DSpace object dicts (with _links.self or type + uuid), REST
23
+ URLs, or (type, id) tuples such as ("item", uuid) or ("workspaceitem", 42).
24
+
25
+ Without eperson the server evaluates the logged-in user (anonymous when not
26
+ logged in); asking about another eperson needs admin rights.
27
+ """
28
+
29
+ import logging
30
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union
31
+
32
+ from ._base import BaseManager
33
+ from .exceptions import AuthorizationError, DSpaceError
34
+ from .metadata import DSO_PATHS
35
+ from .projection import EmbedArg
36
+ from .utils import validate_uuid
37
+
38
+ # Logger for this module
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # API endpoints for authorizations and features
42
+ AUTHORIZATIONS = "/authz/authorizations"
43
+ FEATURES = "/authz/features"
44
+
45
+ # object type -> REST collection path (DSO_PATHS plus non-DSpace-object resources)
46
+ TYPE_PATHS = dict(DSO_PATHS, **{
47
+ "workspaceitem": "/submission/workspaceitems",
48
+ "workflowitem": "/workflow/workflowitems",
49
+ "version": "/versioning/versions",
50
+ "versionhistory": "/versioning/versionhistories",
51
+ "resourcepolicy": "/authz/resourcepolicies",
52
+ "process": "/system/processes",
53
+ })
54
+
55
+ # Type aliases for object and eperson references
56
+ ObjectRef = Union[str, Dict[str, Any], Tuple[str, Any]]
57
+ EPersonRef = Union[None, str, Dict[str, Any]]
58
+
59
+
60
+ class Feature:
61
+ """
62
+ Feature ids known from DSpace 7.6 - 9 (Angular FeatureID). Which ones a
63
+ server offers depends on version and configuration; list_features()
64
+ (admin) shows the real set.
65
+ """
66
+ ADMINISTRATOR_OF = "administratorOf"
67
+ IS_COLLECTION_ADMIN = "isCollectionAdmin" # on the site: admin of at least one collection
68
+ IS_COMMUNITY_ADMIN = "isCommunityAdmin" # on the site: admin of at least one community
69
+ LOGIN_ON_BEHALF_OF = "loginOnBehalfOf"
70
+ CAN_CHANGE_PASSWORD = "canChangePassword"
71
+ CAN_MANAGE_GROUP = "canManageGroup"
72
+ CAN_MANAGE_GROUPS = "canManageGroups"
73
+ CAN_MANAGE_POLICIES = "canManagePolicies"
74
+ CAN_MANAGE_MAPPINGS = "canManageMappings"
75
+ CAN_MANAGE_RELATIONSHIPS = "canManageRelationships"
76
+ CAN_MANAGE_BITSTREAM_BUNDLES = "canManageBitstreamBundles"
77
+ CAN_MANAGE_VERSIONS = "canManageVersions"
78
+ CAN_CREATE_VERSION = "canCreateVersion"
79
+ CAN_EDIT_VERSION = "canEditVersion"
80
+ CAN_DELETE_VERSION = "canDeleteVersion"
81
+ CAN_EDIT_ITEM = "canEditItem"
82
+ CAN_EDIT_METADATA = "canEditMetadata"
83
+ CAN_DELETE = "canDelete"
84
+ CAN_MOVE = "canMove"
85
+ CAN_MAKE_PRIVATE = "canMakePrivate"
86
+ WITHDRAW_ITEM = "withdrawItem"
87
+ REINSTATE_ITEM = "reinstateItem"
88
+ CAN_REORDER_BITSTREAMS = "canReorderBitstreams"
89
+ CAN_CREATE_BITSTREAM = "canCreateBitstream"
90
+ CAN_CREATE_BUNDLE = "canCreateBundle"
91
+ CAN_DOWNLOAD = "canDownload"
92
+ CAN_REQUEST_A_COPY = "canRequestACopy"
93
+ CAN_REGISTER_DOI = "canRegisterDOI"
94
+ CAN_SUBSCRIBE = "canSubscribeDso"
95
+ CAN_VIEW_USAGE_STATISTICS = "canViewUsageStatistics"
96
+ CAN_SEND_FEEDBACK = "canSendFeedback"
97
+ CAN_CLAIM_ITEM = "canClaimItem"
98
+ SHOW_CLAIM_ITEM = "showClaimItem"
99
+ CAN_SYNCHRONIZE_WITH_ORCID = "canSynchronizeWithORCID"
100
+ EPERSON_REGISTRATION = "epersonRegistration"
101
+ EPERSON_FORGOT_PASSWORD = "epersonForgotPassword"
102
+
103
+
104
+ def parse_authorization_id(authorization_id: str) -> Dict[str, Optional[str]]:
105
+ """
106
+ Parse an authorization ID into its components.
107
+
108
+ '[eperson-uuid_]feature_category.model_object-id' ->
109
+ {"eperson": uuid|None, "feature": ..., "object_type": "core.item", "object_id": ...}
110
+
111
+ Parameters:
112
+ authorization_id (str): The authorization ID string to parse.
113
+
114
+ Returns:
115
+ Dict[str, Optional[str]]: A dictionary with keys "eperson", "feature", "object_type", and "object_id".
116
+ """
117
+ parts = str(authorization_id).split("_")
118
+ eperson = parts[0] if len(parts) >= 4 and validate_uuid(parts[0]) else None
119
+ rest = parts[1:] if eperson else parts
120
+ if len(rest) < 3:
121
+ raise ValueError(f"Not an authorization id: {authorization_id!r}")
122
+ return {"eperson": eperson, "feature": rest[0], "object_type": rest[1], "object_id": "_".join(rest[2:])}
123
+
124
+
125
+ def feature_of(authorization: Dict[str, Any]) -> Optional[str]:
126
+ """Feature id of an authorization (embedded feature, else parsed from the id).
127
+
128
+ Parameters:
129
+ authorization (Dict[str, Any]): The authorization object to extract the feature from.
130
+
131
+ Returns:
132
+ Optional[str]: The feature ID if available, None otherwise.
133
+ """
134
+ embedded = (authorization.get("_embedded") or {}).get("feature") or {}
135
+ if embedded.get("id"):
136
+ return embedded["id"]
137
+ try:
138
+ return parse_authorization_id(authorization.get("id", ""))["feature"]
139
+ except ValueError:
140
+ return None
141
+
142
+
143
+ class AuthorizationManager(BaseManager):
144
+ """Read-only permission checks via /authz/authorizations and /authz/features"""
145
+
146
+ # ------------------------------------------------------------------
147
+ # Object references
148
+ # ------------------------------------------------------------------
149
+
150
+ def object_uri(self, obj: ObjectRef) -> str:
151
+ """Absolute REST URL of the object an authorization check is about.
152
+
153
+ Parameters:
154
+ obj (ObjectRef): The object reference to build the URI for.
155
+
156
+ Returns:
157
+ str: The absolute REST URL of the object.
158
+ """
159
+ if isinstance(obj, tuple):
160
+ kind, ident = obj
161
+ if kind not in TYPE_PATHS:
162
+ raise ValueError(f"Unknown object type {kind!r}; known: {sorted(TYPE_PATHS)}")
163
+ return self._url(TYPE_PATHS[kind], f"/{ident}")
164
+ if isinstance(obj, dict):
165
+ href = ((obj.get("_links") or {}).get("self") or {}).get("href")
166
+ if href:
167
+ return self.resolve_href(href)
168
+ ident = obj.get("uuid") or obj.get("id")
169
+ if obj.get("type") and ident is not None:
170
+ return self.object_uri((obj["type"], ident))
171
+ raise ValueError("Object dict needs _links.self.href or type + uuid/id")
172
+ if isinstance(obj, str) and obj.startswith(("http://", "https://", "/")):
173
+ return self.resolve_href(obj)
174
+ raise ValueError(f"Cannot build an object URI from {obj!r} - pass a dict, URL or (type, id)")
175
+
176
+ def _eperson_param(self, eperson: EPersonRef) -> Optional[str]:
177
+ """Get the UUID of an eperson reference.
178
+
179
+ Parameters:
180
+ eperson (EPersonRef): The eperson reference to extract the UUID from.
181
+
182
+ Returns:
183
+ Optional[str]: The UUID of the eperson if available, None otherwise.
184
+ """
185
+ if eperson is None:
186
+ return None
187
+ uuid = self._uuid_of(eperson)
188
+ if not uuid:
189
+ raise ValueError(f"eperson must be a UUID or eperson dict, not {eperson!r}")
190
+ return uuid
191
+
192
+ # ------------------------------------------------------------------
193
+ # Authorizations
194
+ # ------------------------------------------------------------------
195
+
196
+ def get_authorization(self, authorization_id: str, embed: EmbedArg = None) -> Dict[str, Any]:
197
+ """Get an authorization by its ID.
198
+
199
+ Parameters:
200
+ authorization_id (str): The ID of the authorization to retrieve.
201
+ embed (EmbedArg, optional): Embed related resources. Defaults to None.
202
+
203
+ Returns:
204
+ Dict[str, Any]: The authorization object.
205
+ """
206
+ return self._get_json(self._url(AUTHORIZATIONS, f"/{authorization_id}"),
207
+ f"Get authorization {authorization_id}", embed=embed)
208
+
209
+ def search_object(self, obj: ObjectRef, feature: Optional[str] = None, eperson: EPersonRef = None,
210
+ embed: EmbedArg = None) -> List[Dict[str, Any]]:
211
+ """All authorizations (optionally one feature) the user has on one object.
212
+
213
+ Parameters:
214
+ obj (ObjectRef): The object reference to check authorizations on.
215
+ feature (str, optional): The feature to filter authorizations by. Defaults to None.
216
+ eperson (EPersonRef, optional): The eperson reference to check authorizations for. Defaults to None.
217
+ embed (EmbedArg, optional): Embed related resources. Defaults to None.
218
+
219
+ Returns:
220
+ List[Dict[str, Any]]: A list of authorization objects.
221
+ """
222
+ params = [("uri", self.object_uri(obj))]
223
+ if feature:
224
+ params.append(("feature", feature))
225
+ eperson_uuid = self._eperson_param(eperson)
226
+ if eperson_uuid:
227
+ params.append(("eperson", eperson_uuid))
228
+ url = self._url(AUTHORIZATIONS, "/search/object")
229
+ return list(self._iter_embedded(url, "authorizations", "Search authorizations", params, embed=embed))
230
+
231
+ def features_on(self, obj: ObjectRef, eperson: EPersonRef = None) -> Set[str]:
232
+ """Every feature id the user has on the object (one request).
233
+
234
+ Parameters:
235
+ obj (ObjectRef): The object reference to check features on.
236
+ eperson (EPersonRef, optional): The eperson reference to check features for. Defaults to None.
237
+
238
+ Returns:
239
+ Set[str]: A set of feature IDs the user has on the object.
240
+ """
241
+ return {f for f in (feature_of(a) for a in self.search_object(obj, eperson=eperson, embed="feature")) if f}
242
+
243
+ def can(self, obj: ObjectRef, feature: str, eperson: EPersonRef = None) -> bool:
244
+ """Check if the user has a specific feature on an object.
245
+
246
+ Parameters:
247
+ obj (ObjectRef): The object reference to check the feature on.
248
+ feature (str): The feature to check.
249
+ eperson (EPersonRef, optional): The eperson reference to check the feature for. Defaults to None.
250
+
251
+ Returns:
252
+ bool: True if the feature is granted, False otherwise.
253
+ """
254
+ return bool(self.search_object(obj, feature=feature, eperson=eperson))
255
+
256
+ def check(self, obj: ObjectRef, features: Iterable[str], eperson: EPersonRef = None) -> Dict[str, bool]:
257
+ """{feature: bool} for several features on one object, in one request.
258
+
259
+ Parameters:
260
+ obj (ObjectRef): The object reference to check features on.
261
+ features (Iterable[str]): The features to check.
262
+ eperson (EPersonRef, optional): The eperson reference to check features for. Defaults to None.
263
+
264
+ Returns:
265
+ Dict[str, bool]: A dictionary mapping feature IDs to their granted status.
266
+ """
267
+ granted = self.features_on(obj, eperson)
268
+ return {feature: feature in granted for feature in features}
269
+
270
+ def require(self, obj: ObjectRef, feature: str, eperson: EPersonRef = None) -> None:
271
+ """Raise AuthorizationError unless the feature is granted - a pre-flight check for scripts.
272
+
273
+ Parameters:
274
+ obj (ObjectRef): The object reference to check the feature on.
275
+ feature (str): The feature to check.
276
+ eperson (EPersonRef, optional): The eperson reference to check the feature for. Defaults to None.
277
+
278
+ Raises:
279
+ AuthorizationError: If the feature is not granted.
280
+ """
281
+ if not self.can(obj, feature, eperson):
282
+ who = f"eperson {self._eperson_param(eperson)}" if eperson else "the current user"
283
+ raise AuthorizationError(f"{who} lacks {feature!r} on {self.object_uri(obj)}", status_code=403)
284
+
285
+ # ------------------------------------------------------------------
286
+ # Batch checks
287
+ # ------------------------------------------------------------------
288
+
289
+ @staticmethod
290
+ def _rest_type(path: str) -> str:
291
+ """
292
+ Convert a REST API path to the type parameter used in search/objects.
293
+
294
+ Parameters:
295
+ path (str): The REST API path.
296
+
297
+ Returns:
298
+ str: The corresponding type parameter.
299
+
300
+ Example:
301
+ '/core/items' -> 'core.items' (the type parameter of search/objects)."""
302
+ return ".".join(path.strip("/").split("/"))
303
+
304
+ def _group_by_type(self, objects: Iterable[ObjectRef]) -> Dict[str, List[Tuple[str, ObjectRef]]]:
305
+ """
306
+ Group objects by their REST API path type.
307
+
308
+ Parameters:
309
+ objects (Iterable[ObjectRef]): The objects to group.
310
+
311
+ Returns:
312
+ Dict[str, List[Tuple[str, ObjectRef]]]: A dictionary mapping path types to lists of (identifier, object) tuples.
313
+ """
314
+ grouped: Dict[str, List[Tuple[str, ObjectRef]]] = {}
315
+ for obj in objects:
316
+ uri = self.object_uri(obj)
317
+ path, _, ident = uri[len(self.config.dspace_url):].rpartition("/")
318
+ grouped.setdefault(path, []).append((ident, obj))
319
+ return grouped
320
+
321
+ def features_on_many(self, objects: Sequence[ObjectRef], features: Union[str, Iterable[str], None] = None,
322
+ eperson: EPersonRef = None, batch_size: int = 50) -> Dict[str, Set[str]]:
323
+ """
324
+ Get granted features for many objects using search/objects.
325
+
326
+ Parameters:
327
+ objects (Sequence[ObjectRef]): The objects to check features on.
328
+ features (Union[str, Iterable[str], None], optional): The features to check. Defaults to None.
329
+ eperson (EPersonRef, optional): The eperson reference to check features for. Defaults to None.
330
+ batch_size (int, optional): The batch size for search/objects requests. Defaults to 50.
331
+
332
+ Returns:
333
+ Dict[str, Set[str]]: A dictionary mapping object IDs to sets of granted features.
334
+
335
+ Notes:
336
+ {object id: granted features} for many objects with search/objects
337
+ (DSpace 7.6+; one request per type and batch). Without `features`
338
+ (search/objects returns nothing then), on servers without that
339
+ search, and when it fails server-side, it falls back to one
340
+ search/object request per object.
341
+ """
342
+ wanted = [features] if isinstance(features, str) else list(features or [])
343
+ eperson_uuid = self._eperson_param(eperson)
344
+ result: Dict[str, Set[str]] = {}
345
+ url = self._url(AUTHORIZATIONS, "/search/objects")
346
+ for path, entries in self._group_by_type(objects).items():
347
+ for ident, _ in entries:
348
+ result[ident] = set()
349
+ if not wanted:
350
+ # search/objects needs feature names - it answers an empty page without them
351
+ for ident, obj in entries:
352
+ result[ident] = self.features_on(obj, eperson)
353
+ continue
354
+ for start in range(0, len(entries), batch_size):
355
+ batch = entries[start:start + batch_size]
356
+ params = [("type", self._rest_type(path))] + [("uuid", ident) for ident, _ in batch]
357
+ params += [("feature", f) for f in wanted]
358
+ if eperson_uuid:
359
+ params.append(("eperson", eperson_uuid))
360
+ try:
361
+ found = list(self._iter_embedded(url, "authorizations", "Batch authorization search", params,
362
+ embed="feature"))
363
+ except DSpaceError as exc:
364
+ status = getattr(exc, "status_code", None)
365
+ if status not in (400, 404, 405, 422) and not (status or 0) >= 500:
366
+ raise
367
+ logger.warning("search/objects failed (HTTP %s: %s), checking one by one", status,
368
+ (exc.response_text or "")[:200])
369
+ for ident, obj in batch:
370
+ granted = self.features_on(obj, eperson)
371
+ result[ident] = {f for f in granted if not wanted or f in wanted}
372
+ continue
373
+ for authorization in found:
374
+ try:
375
+ ident = parse_authorization_id(authorization.get("id", ""))["object_id"]
376
+ except ValueError:
377
+ continue
378
+ feature = feature_of(authorization)
379
+ if ident in result and feature and (not wanted or feature in wanted):
380
+ result[ident].add(feature)
381
+ return result
382
+
383
+ def can_many(self, objects: Sequence[ObjectRef], feature: str, eperson: EPersonRef = None) -> Dict[str, bool]:
384
+ """
385
+ Get a dictionary mapping object IDs to a boolean indicating if the feature is granted.
386
+
387
+ Parameters:
388
+ objects (Sequence[ObjectRef]): The objects to check the feature on.
389
+ feature (str): The feature to check.
390
+ eperson (EPersonRef, optional): The eperson reference to check the feature for. Defaults to None.
391
+
392
+ Returns:
393
+ Dict[str, bool]: A dictionary mapping object IDs to a boolean indicating if the feature is granted.
394
+ """
395
+ return {ident: feature in granted
396
+ for ident, granted in self.features_on_many(objects, feature, eperson).items()}
397
+
398
+ # ------------------------------------------------------------------
399
+ # Convenience
400
+ # ------------------------------------------------------------------
401
+
402
+ def site_uri(self) -> str:
403
+ """
404
+ Get the URI of the site object.
405
+
406
+ Returns:
407
+ str: The URI of the site object.
408
+
409
+ Raises:
410
+ ValueError: If no site object is found.
411
+ """
412
+ from .site import SiteManager
413
+ site = SiteManager(self.auth).get_site(all_languages=False)
414
+ if not site:
415
+ raise ValueError("No Site object found")
416
+ return self._url(DSO_PATHS["site"], f"/{site['uuid']}")
417
+
418
+ def is_site_admin(self, eperson: EPersonRef = None) -> bool:
419
+ """
420
+ Check if the eperson is a site administrator.
421
+
422
+ Parameters:
423
+ eperson (EPersonRef, optional): The eperson reference to check. Defaults to None.
424
+
425
+ Returns:
426
+ bool: True if the eperson is a site administrator, False otherwise.
427
+ """
428
+ return self.can(self.site_uri(), Feature.ADMINISTRATOR_OF, eperson)
429
+
430
+ def is_admin_of(self, obj: ObjectRef, eperson: EPersonRef = None) -> bool:
431
+ """Admin of this community/collection/item (directly, inherited, or site admin)."""
432
+ return self.can(obj, Feature.ADMINISTRATOR_OF, eperson)
433
+
434
+ def admin_scope(self, eperson: EPersonRef = None) -> Dict[str, bool]:
435
+ """
436
+ Get the administrative scope of the eperson.
437
+
438
+ Parameters:
439
+ eperson (EPersonRef, optional): The eperson reference to check the admin scope for. Defaults to None.
440
+
441
+ Returns:
442
+ Dict[str, bool]: A dictionary indicating which admin menus apply.
443
+ Keys are "site", "any_community", and "any_collection".
444
+ """
445
+ granted = self.features_on(self.site_uri(), eperson)
446
+ return {"site": Feature.ADMINISTRATOR_OF in granted,
447
+ "any_community": Feature.IS_COMMUNITY_ADMIN in granted,
448
+ "any_collection": Feature.IS_COLLECTION_ADMIN in granted}
449
+
450
+ # ------------------------------------------------------------------
451
+ # Features (admin only)
452
+ # ------------------------------------------------------------------
453
+
454
+ def list_features(self) -> List[Dict[str, Any]]:
455
+ """
456
+ List all authorization features.
457
+
458
+ Returns:
459
+ List[Dict[str, Any]]: A list of all authorization features.
460
+ """
461
+ return list(self._iter_embedded(self._url(FEATURES), "features", "List authorization features"))
462
+
463
+ def get_feature(self, feature_id: str) -> Dict[str, Any]:
464
+ """
465
+ Get details of a specific authorization feature.
466
+
467
+ Parameters:
468
+ feature_id (str): The ID of the feature to retrieve.
469
+
470
+ Returns:
471
+ Dict[str, Any]: The details of the specified feature.
472
+ """
473
+ return self._get_json(self._url(FEATURES, f"/{feature_id}"), f"Get feature {feature_id}")
474
+
475
+ def features_for_type(self, resource_type: str) -> List[Dict[str, Any]]:
476
+ """
477
+ Get features that apply to a specific resource type.
478
+
479
+ Parameters:
480
+ resource_type (str): The resource type to get features for, e.g. 'core.item', 'core.collection', 'core.site'.
481
+
482
+ Returns:
483
+ List[Dict[str, Any]]: A list of features applicable to the specified resource type.
484
+ """
485
+ url = self._url(FEATURES, "/search/resourcetype")
486
+ return list(self._iter_embedded(url, "features", f"Features for {resource_type}", [("type", resource_type)]))