kinto 23.2.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.
Files changed (142) hide show
  1. kinto/__init__.py +92 -0
  2. kinto/__main__.py +249 -0
  3. kinto/authorization.py +134 -0
  4. kinto/config/__init__.py +94 -0
  5. kinto/config/kinto.tpl +270 -0
  6. kinto/contribute.json +27 -0
  7. kinto/core/__init__.py +246 -0
  8. kinto/core/authentication.py +48 -0
  9. kinto/core/authorization.py +311 -0
  10. kinto/core/cache/__init__.py +131 -0
  11. kinto/core/cache/memcached.py +112 -0
  12. kinto/core/cache/memory.py +104 -0
  13. kinto/core/cache/postgresql/__init__.py +178 -0
  14. kinto/core/cache/postgresql/schema.sql +23 -0
  15. kinto/core/cache/testing.py +208 -0
  16. kinto/core/cornice/__init__.py +93 -0
  17. kinto/core/cornice/cors.py +144 -0
  18. kinto/core/cornice/errors.py +40 -0
  19. kinto/core/cornice/pyramidhook.py +373 -0
  20. kinto/core/cornice/renderer.py +89 -0
  21. kinto/core/cornice/resource.py +205 -0
  22. kinto/core/cornice/service.py +641 -0
  23. kinto/core/cornice/util.py +138 -0
  24. kinto/core/cornice/validators/__init__.py +94 -0
  25. kinto/core/cornice/validators/_colander.py +142 -0
  26. kinto/core/cornice/validators/_marshmallow.py +182 -0
  27. kinto/core/cornice_swagger/__init__.py +92 -0
  28. kinto/core/cornice_swagger/converters/__init__.py +21 -0
  29. kinto/core/cornice_swagger/converters/exceptions.py +6 -0
  30. kinto/core/cornice_swagger/converters/parameters.py +90 -0
  31. kinto/core/cornice_swagger/converters/schema.py +249 -0
  32. kinto/core/cornice_swagger/swagger.py +725 -0
  33. kinto/core/cornice_swagger/templates/index.html +73 -0
  34. kinto/core/cornice_swagger/templates/index_script_template.html +21 -0
  35. kinto/core/cornice_swagger/util.py +42 -0
  36. kinto/core/cornice_swagger/views.py +78 -0
  37. kinto/core/decorators.py +74 -0
  38. kinto/core/errors.py +216 -0
  39. kinto/core/events.py +301 -0
  40. kinto/core/initialization.py +738 -0
  41. kinto/core/listeners/__init__.py +9 -0
  42. kinto/core/metrics.py +94 -0
  43. kinto/core/openapi.py +115 -0
  44. kinto/core/permission/__init__.py +202 -0
  45. kinto/core/permission/memory.py +167 -0
  46. kinto/core/permission/postgresql/__init__.py +489 -0
  47. kinto/core/permission/postgresql/migrations/migration_001_002.sql +18 -0
  48. kinto/core/permission/postgresql/schema.sql +41 -0
  49. kinto/core/permission/testing.py +487 -0
  50. kinto/core/resource/__init__.py +1311 -0
  51. kinto/core/resource/model.py +412 -0
  52. kinto/core/resource/schema.py +502 -0
  53. kinto/core/resource/viewset.py +230 -0
  54. kinto/core/schema.py +119 -0
  55. kinto/core/scripts.py +50 -0
  56. kinto/core/statsd.py +1 -0
  57. kinto/core/storage/__init__.py +436 -0
  58. kinto/core/storage/exceptions.py +53 -0
  59. kinto/core/storage/generators.py +58 -0
  60. kinto/core/storage/memory.py +651 -0
  61. kinto/core/storage/postgresql/__init__.py +1131 -0
  62. kinto/core/storage/postgresql/client.py +120 -0
  63. kinto/core/storage/postgresql/migrations/migration_001_002.sql +10 -0
  64. kinto/core/storage/postgresql/migrations/migration_002_003.sql +33 -0
  65. kinto/core/storage/postgresql/migrations/migration_003_004.sql +18 -0
  66. kinto/core/storage/postgresql/migrations/migration_004_005.sql +20 -0
  67. kinto/core/storage/postgresql/migrations/migration_005_006.sql +11 -0
  68. kinto/core/storage/postgresql/migrations/migration_006_007.sql +74 -0
  69. kinto/core/storage/postgresql/migrations/migration_007_008.sql +66 -0
  70. kinto/core/storage/postgresql/migrations/migration_008_009.sql +41 -0
  71. kinto/core/storage/postgresql/migrations/migration_009_010.sql +98 -0
  72. kinto/core/storage/postgresql/migrations/migration_010_011.sql +14 -0
  73. kinto/core/storage/postgresql/migrations/migration_011_012.sql +9 -0
  74. kinto/core/storage/postgresql/migrations/migration_012_013.sql +71 -0
  75. kinto/core/storage/postgresql/migrations/migration_013_014.sql +14 -0
  76. kinto/core/storage/postgresql/migrations/migration_014_015.sql +95 -0
  77. kinto/core/storage/postgresql/migrations/migration_015_016.sql +4 -0
  78. kinto/core/storage/postgresql/migrations/migration_016_017.sql +81 -0
  79. kinto/core/storage/postgresql/migrations/migration_017_018.sql +25 -0
  80. kinto/core/storage/postgresql/migrations/migration_018_019.sql +8 -0
  81. kinto/core/storage/postgresql/migrations/migration_019_020.sql +7 -0
  82. kinto/core/storage/postgresql/migrations/migration_020_021.sql +68 -0
  83. kinto/core/storage/postgresql/migrations/migration_021_022.sql +62 -0
  84. kinto/core/storage/postgresql/migrations/migration_022_023.sql +5 -0
  85. kinto/core/storage/postgresql/migrations/migration_023_024.sql +6 -0
  86. kinto/core/storage/postgresql/migrations/migration_024_025.sql +6 -0
  87. kinto/core/storage/postgresql/migrator.py +98 -0
  88. kinto/core/storage/postgresql/pool.py +55 -0
  89. kinto/core/storage/postgresql/schema.sql +143 -0
  90. kinto/core/storage/testing.py +1857 -0
  91. kinto/core/storage/utils.py +37 -0
  92. kinto/core/testing.py +182 -0
  93. kinto/core/utils.py +553 -0
  94. kinto/core/views/__init__.py +0 -0
  95. kinto/core/views/batch.py +163 -0
  96. kinto/core/views/errors.py +145 -0
  97. kinto/core/views/heartbeat.py +106 -0
  98. kinto/core/views/hello.py +69 -0
  99. kinto/core/views/openapi.py +35 -0
  100. kinto/core/views/version.py +50 -0
  101. kinto/events.py +3 -0
  102. kinto/plugins/__init__.py +0 -0
  103. kinto/plugins/accounts/__init__.py +94 -0
  104. kinto/plugins/accounts/authentication.py +63 -0
  105. kinto/plugins/accounts/scripts.py +61 -0
  106. kinto/plugins/accounts/utils.py +13 -0
  107. kinto/plugins/accounts/views.py +136 -0
  108. kinto/plugins/admin/README.md +3 -0
  109. kinto/plugins/admin/VERSION +1 -0
  110. kinto/plugins/admin/__init__.py +40 -0
  111. kinto/plugins/admin/build/VERSION +1 -0
  112. kinto/plugins/admin/build/assets/index-CYFwtKtL.css +6 -0
  113. kinto/plugins/admin/build/assets/index-DJ0m93zA.js +149 -0
  114. kinto/plugins/admin/build/assets/logo-VBRiKSPX.png +0 -0
  115. kinto/plugins/admin/build/index.html +18 -0
  116. kinto/plugins/admin/public/help.html +25 -0
  117. kinto/plugins/admin/views.py +42 -0
  118. kinto/plugins/default_bucket/__init__.py +191 -0
  119. kinto/plugins/flush.py +28 -0
  120. kinto/plugins/history/__init__.py +65 -0
  121. kinto/plugins/history/listener.py +181 -0
  122. kinto/plugins/history/views.py +66 -0
  123. kinto/plugins/openid/__init__.py +131 -0
  124. kinto/plugins/openid/utils.py +14 -0
  125. kinto/plugins/openid/views.py +193 -0
  126. kinto/plugins/prometheus.py +300 -0
  127. kinto/plugins/statsd.py +85 -0
  128. kinto/schema_validation.py +135 -0
  129. kinto/views/__init__.py +34 -0
  130. kinto/views/admin.py +195 -0
  131. kinto/views/buckets.py +45 -0
  132. kinto/views/collections.py +58 -0
  133. kinto/views/contribute.py +39 -0
  134. kinto/views/groups.py +90 -0
  135. kinto/views/permissions.py +235 -0
  136. kinto/views/records.py +133 -0
  137. kinto-23.2.1.dist-info/METADATA +232 -0
  138. kinto-23.2.1.dist-info/RECORD +142 -0
  139. kinto-23.2.1.dist-info/WHEEL +5 -0
  140. kinto-23.2.1.dist-info/entry_points.txt +5 -0
  141. kinto-23.2.1.dist-info/licenses/LICENSE +13 -0
  142. kinto-23.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <title>Kinto Administration</title>
7
+ <meta name="viewport" content="width=device-width,initial-scale=1">
8
+ <link rel="icon" type="image/x-icon" href="../images/favicon.png">
9
+ <script type="module" crossorigin src="/v1/admin/assets/index-DJ0m93zA.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/v1/admin/assets/index-CYFwtKtL.css">
11
+ </head>
12
+
13
+ <body>
14
+ <div id="app"></div>
15
+ </body>
16
+
17
+
18
+ </html>
@@ -0,0 +1,25 @@
1
+ <html>
2
+ <body>
3
+ <h1>Kinto Admin</h1>
4
+ <section>
5
+ <h2>What's wrong?</h2>
6
+ The Kinto-Admin UI is a <a href="https://github.com/Kinto/kinto-admin/">JavaScript project</a>
7
+ which is not fully delivered with the server source code.
8
+ </section>
9
+ <section>
10
+ <h2>Build and run locally</h2>
11
+ <p>In order to get a local Kinto Admin running at this address, just run the
12
+ following command:
13
+ </p>
14
+ <code>make pull-kinto-admin</code>
15
+ <p>Restart the server and refresh!</p>
16
+ </section>
17
+ <section>
18
+ <h2>...or use our online version!</h2>
19
+ The Kinto Admin is a fully static Web application. That means you can use
20
+ the online version on your local server.
21
+ Just navigate to <a href="http://kinto.github.io/kinto-admin/">http://kinto.github.io/kinto-admin/</a>
22
+ and set the server address to <em>http://localhost:8888/v1</em>.
23
+ </section>
24
+ </body>
25
+ </html>
@@ -0,0 +1,42 @@
1
+ import os
2
+
3
+ from kinto.core.decorators import cache_forever
4
+
5
+
6
+ HERE = os.path.dirname(__file__)
7
+
8
+
9
+ # Configured home page
10
+ @cache_forever
11
+ def admin_home_view(request):
12
+ """
13
+ This view reads the ``index.html`` file from the Admin assets path folder
14
+ and serves it.
15
+
16
+ This requires the Admin UI to be built with ``ASSET_PATH="/v1/admin/"``.
17
+ """
18
+ # Default location of the Admin UI is relative to this plugin source folder,
19
+ # as pulled with the ``make pull-kinto-admin`` command.
20
+ admin_assets_path = request.registry.settings["admin_assets_path"] or os.path.join(
21
+ HERE, "build"
22
+ )
23
+ try:
24
+ with open(os.path.join(admin_assets_path, "index.html")) as f:
25
+ page_content = f.read()
26
+ except FileNotFoundError: # pragma: no cover
27
+ with open(os.path.join(HERE, "public", "help.html")) as f:
28
+ page_content = f.read()
29
+
30
+ # Add Content-Security-Policy HTTP response header to protect against XSS:
31
+ # only allow from local domain:
32
+ allow_local_only = "; ".join(
33
+ (
34
+ "default-src 'self'",
35
+ "img-src data: *",
36
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
37
+ "style-src 'self' 'unsafe-inline'",
38
+ )
39
+ )
40
+ request.response.headers["Content-Security-Policy"] = allow_local_only
41
+
42
+ return page_content
@@ -0,0 +1,191 @@
1
+ import uuid
2
+
3
+ from pyramid import httpexceptions
4
+ from pyramid.authorization import Authenticated
5
+ from pyramid.security import NO_PERMISSION_REQUIRED
6
+ from pyramid.settings import asbool
7
+
8
+ from kinto.authorization import RouteFactory
9
+ from kinto.core import get_user_info as core_get_user_info
10
+ from kinto.core.errors import raise_invalid
11
+ from kinto.core.events import ACTIONS
12
+ from kinto.core.storage.exceptions import UnicityError
13
+ from kinto.core.utils import build_request, hmac_digest, instance_uri, reapply_cors, view_lookup
14
+ from kinto.views.buckets import Bucket
15
+ from kinto.views.collections import Collection
16
+
17
+
18
+ def create_bucket(request, bucket_id):
19
+ """Create a bucket if it doesn't exists."""
20
+ bucket_put = request.method.lower() == "put" and request.path.endswith("buckets/default")
21
+ # Do nothing if current request will already create the bucket.
22
+ if bucket_put:
23
+ return
24
+
25
+ # Do not intent to create multiple times per request (e.g. in batch).
26
+ already_created = request.bound_data.setdefault("buckets", {})
27
+ if bucket_id in already_created:
28
+ return
29
+
30
+ bucket_uri = instance_uri(request, "bucket", id=bucket_id)
31
+ bucket = resource_create_object(request=request, resource_cls=Bucket, uri=bucket_uri)
32
+ already_created[bucket_id] = bucket
33
+
34
+
35
+ def create_collection(request, bucket_id):
36
+ # Do nothing if current request does not involve a collection.
37
+ subpath = request.matchdict.get("subpath")
38
+ if not (subpath and subpath.rstrip("/").startswith("collections/")):
39
+ return
40
+
41
+ collection_id = subpath.split("/")[1]
42
+ collection_uri = instance_uri(request, "collection", bucket_id=bucket_id, id=collection_id)
43
+
44
+ # Do not intent to create multiple times per request (e.g. in batch).
45
+ already_created = request.bound_data.setdefault("collections", {})
46
+ if collection_uri in already_created:
47
+ return
48
+
49
+ # Do nothing if current request will already create the collection.
50
+ collection_put = request.method.lower() == "put" and request.path.endswith(collection_id)
51
+ if collection_put:
52
+ return
53
+
54
+ collection = resource_create_object(
55
+ request=request, resource_cls=Collection, uri=collection_uri
56
+ )
57
+ already_created[collection_uri] = collection
58
+
59
+
60
+ def resource_create_object(request, resource_cls, uri):
61
+ """Implicitly create a resource (or fail silently).
62
+
63
+ In the default bucket, the bucket and collection are implicitly
64
+ created. This helper creates one of those resources using a
65
+ simulated request and context that is appropriate for the
66
+ resource. Also runs create events as though the resource were
67
+ created in a subrequest.
68
+
69
+ If the resource already exists, do nothing.
70
+
71
+ """
72
+ resource_name, matchdict = view_lookup(request, uri)
73
+
74
+ # Build a fake request, mainly used to populate the create events that
75
+ # will be triggered by the resource.
76
+ fakerequest = build_request(request, {"method": "PUT", "path": uri})
77
+ fakerequest.matchdict = matchdict
78
+ fakerequest.bound_data = request.bound_data
79
+ fakerequest.authn_type = request.authn_type
80
+ fakerequest.selected_userid = request.selected_userid
81
+ fakerequest.errors = request.errors
82
+ fakerequest.current_resource_name = resource_name
83
+
84
+ obj_id = matchdict["id"]
85
+
86
+ # Fake context, required to instantiate a resource.
87
+ context = RouteFactory(fakerequest)
88
+ context.resource_name = resource_name
89
+ resource = resource_cls(fakerequest, context)
90
+
91
+ # Check that provided id is valid for this resource.
92
+ if not resource.model.id_generator.match(obj_id):
93
+ error_details = {"location": "path", "description": f"Invalid {resource_name} id"}
94
+ raise_invalid(resource.request, **error_details)
95
+
96
+ data = {"id": obj_id}
97
+ try:
98
+ obj = resource.model.create_object(data)
99
+ except UnicityError:
100
+ # The record already exists; skip running events
101
+ return {}
102
+
103
+ # Since the current request is not a resource (but a straight Service),
104
+ # we simulate a request on a resource.
105
+ # This will be used in the resource event payload.
106
+ resource.postprocess(obj, action=ACTIONS.CREATE)
107
+ return obj
108
+
109
+
110
+ def default_bucket(request):
111
+ if request.method.lower() == "options":
112
+ path = request.path.replace("default", "unknown")
113
+ subrequest = build_request(request, {"method": "OPTIONS", "path": path})
114
+ return request.invoke_subrequest(subrequest)
115
+
116
+ if Authenticated not in request.effective_principals:
117
+ # Pass through the forbidden_view_config
118
+ raise httpexceptions.HTTPForbidden()
119
+
120
+ settings = request.registry.settings
121
+
122
+ if asbool(settings["readonly"]):
123
+ raise httpexceptions.HTTPMethodNotAllowed()
124
+
125
+ bucket_id = request.default_bucket_id
126
+
127
+ # Implicit object creations.
128
+ # Make sure bucket exists
129
+ create_bucket(request, bucket_id)
130
+ # Make sure the collection exists
131
+ create_collection(request, bucket_id)
132
+
133
+ path = request.path.replace("/buckets/default", f"/buckets/{bucket_id}")
134
+ querystring = request.url[(request.url.index(request.path) + len(request.path)) :]
135
+ try:
136
+ # If 'id' is provided as 'default', replace with actual bucket id.
137
+ body = request.json
138
+ body["data"]["id"] = body["data"]["id"].replace("default", bucket_id)
139
+ except Exception:
140
+ body = request.body or {"data": {}}
141
+ subrequest = build_request(
142
+ request, {"method": request.method, "path": path + querystring, "body": body}
143
+ )
144
+ subrequest.bound_data = request.bound_data
145
+
146
+ try:
147
+ response = request.invoke_subrequest(subrequest)
148
+ except httpexceptions.HTTPException as error:
149
+ is_redirect = error.status_code < 400
150
+ if error.content_type == "application/json" or is_redirect:
151
+ response = reapply_cors(subrequest, error)
152
+ else:
153
+ # Ask the upper level to format the error.
154
+ raise error
155
+ return response
156
+
157
+
158
+ def default_bucket_id(request):
159
+ settings = request.registry.settings
160
+ secret = settings.get("default_bucket_hmac_secret", settings["userid_hmac_secret"])
161
+ # Build the user unguessable bucket_id UUID from its user_id
162
+ digest = hmac_digest(secret, request.prefixed_userid)
163
+ return str(uuid.UUID(digest[:32]))
164
+
165
+
166
+ def get_user_info(request):
167
+ user_info = {**core_get_user_info(request), "bucket": request.default_bucket_id}
168
+ return user_info
169
+
170
+
171
+ def includeme(config):
172
+ # Redirect default to the right endpoint
173
+ config.add_view(default_bucket, route_name="default_bucket", permission=NO_PERMISSION_REQUIRED)
174
+ config.add_view(
175
+ default_bucket, route_name="default_bucket_collection", permission=NO_PERMISSION_REQUIRED
176
+ )
177
+
178
+ config.add_route("default_bucket_collection", "/buckets/default/{subpath:.*}")
179
+ config.add_route("default_bucket", "/buckets/default")
180
+
181
+ # Provide helpers
182
+ config.add_request_method(default_bucket_id, reify=True)
183
+ # Override kinto.core default user info
184
+ config.add_request_method(get_user_info)
185
+
186
+ config.add_api_capability(
187
+ "default_bucket",
188
+ description="The default bucket is an alias for a personal"
189
+ " bucket where collections are created implicitly.",
190
+ url="https://kinto.readthedocs.io/en/latest/api/1.x/buckets.html#personal-bucket-default",
191
+ )
kinto/plugins/flush.py ADDED
@@ -0,0 +1,28 @@
1
+ from pyramid.security import NO_PERMISSION_REQUIRED
2
+
3
+ from kinto.core import Service
4
+ from kinto.events import ServerFlushed
5
+
6
+
7
+ flush = Service(name="flush", description="Clear database content", path="/__flush__")
8
+
9
+
10
+ @flush.post(permission=NO_PERMISSION_REQUIRED)
11
+ def flush_post(request):
12
+ request.registry.storage.flush()
13
+ request.registry.permission.flush()
14
+ request.registry.cache.flush()
15
+ event = ServerFlushed(request)
16
+ request.registry.notify(event)
17
+
18
+ request.response.status = 202
19
+ return {}
20
+
21
+
22
+ def includeme(config):
23
+ config.add_api_capability(
24
+ "flush_endpoint",
25
+ description="The __flush__ endpoint can be used to remove all data from all backends.",
26
+ url="https://kinto.readthedocs.io/en/latest/api/1.x/flush.html",
27
+ )
28
+ config.add_cornice_service(flush)
@@ -0,0 +1,65 @@
1
+ from pyramid.settings import aslist
2
+
3
+ from kinto.authorization import PERMISSIONS_INHERITANCE_TREE
4
+ from kinto.core import metrics
5
+ from kinto.core.events import ResourceChanged
6
+
7
+ from .listener import on_resource_changed
8
+
9
+
10
+ def uri_to_dict(uri):
11
+ """
12
+ Convert a resource URI to a dictionary with its components.
13
+ We don't use `kinto.core.view_lookup_registry()` here because it requires
14
+ a request context or an initialized registry, which is not available at
15
+ this point.
16
+ """
17
+ parts = uri.split("/")
18
+ if len(parts) == 3:
19
+ _, _buckets, bid = parts
20
+ return {"bucket": bid}
21
+ if len(parts) == 5:
22
+ _, _buckets, bid, resource, rid = parts
23
+ if resource == "collections":
24
+ return {"bucket": bid, "collection": rid}
25
+ return {"bucket": bid, "group": rid}
26
+ if len(parts) == 7:
27
+ _, _buckets, bid, _collections, cid, _records, rid = parts
28
+ return {"bucket": bid, "collection": cid, "record": rid}
29
+ raise ValueError(f"Invalid URI: {uri}") # pragma: no cover
30
+
31
+
32
+ def includeme(config):
33
+ settings = config.get_settings()
34
+ exposed_settings = {}
35
+ if (trim_history_max := int(settings.get("history.auto_trim_max_count", "-1"))) > 0:
36
+ exposed_settings["auto_trim_max_count"] = trim_history_max
37
+ if trim_user_ids := aslist(settings.get("history.auto_trim_user_ids", "")):
38
+ exposed_settings["auto_trim_user_ids"] = trim_user_ids
39
+ if excluded_resources := aslist(settings.get("history.exclude_resources", "")):
40
+ exposed_settings["excluded_resources"] = [uri_to_dict(uri) for uri in excluded_resources]
41
+
42
+ config.add_api_capability(
43
+ "history",
44
+ description="Track changes on data.",
45
+ url="http://kinto.readthedocs.io/en/latest/api/1.x/history.html",
46
+ **exposed_settings,
47
+ )
48
+
49
+ # Activate end-points.
50
+ config.scan("kinto.plugins.history.views")
51
+
52
+ wrapped_listener = metrics.listener_with_timer(config, "plugins.history", on_resource_changed)
53
+
54
+ # Listen to every resources (except history)
55
+ config.add_subscriber(
56
+ wrapped_listener,
57
+ ResourceChanged,
58
+ for_resources=("bucket", "group", "collection", "record"),
59
+ )
60
+
61
+ # Register the permission inheritance for history entries.
62
+ PERMISSIONS_INHERITANCE_TREE["history"] = {
63
+ "read": {"bucket": ["write", "read"], "history": ["write", "read"]},
64
+ "write": {"bucket": ["write"], "history": ["write"]},
65
+ }
@@ -0,0 +1,181 @@
1
+ import logging
2
+ from datetime import datetime, timezone
3
+
4
+ from pyramid.settings import asbool, aslist
5
+
6
+ from kinto.core.storage import Filter
7
+ from kinto.core.utils import COMPARISON, instance_uri
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def on_resource_changed(event):
14
+ """
15
+ Everytime an object is created/changed/deleted, we create an entry in the
16
+ ``history`` resource. The entries are served as read-only in the
17
+ :mod:`kinto.plugins.history.views` module.
18
+ """
19
+ payload = event.payload
20
+ resource_name = payload["resource_name"]
21
+ event_uri = payload["uri"]
22
+ user_id = payload["user_id"]
23
+
24
+ storage = event.request.registry.storage
25
+ permission = event.request.registry.permission
26
+ settings = event.request.registry.settings
27
+
28
+ excluded_user_ids = aslist(settings.get("history.exclude_user_ids", ""))
29
+ if user_id in excluded_user_ids:
30
+ logger.info(f"History entries for user {user_id!r} are disabled in config")
31
+ return
32
+
33
+ trim_history_max = int(settings.get("history.auto_trim_max_count", "-1"))
34
+ is_trim_enabled = trim_history_max > 0
35
+ trim_user_ids = aslist(settings.get("history.auto_trim_user_ids", ""))
36
+ is_trim_by_user_enabled = len(trim_user_ids) > 0
37
+
38
+ bucket_id = None
39
+ bucket_uri = None
40
+ collection_uri = None
41
+
42
+ excluded_resources = aslist(settings.get("history.exclude_resources", ""))
43
+
44
+ targets = []
45
+ for impacted in event.impacted_objects:
46
+ target = impacted["new"]
47
+ obj_id = target["id"]
48
+
49
+ try:
50
+ bucket_id = payload["bucket_id"]
51
+ except KeyError:
52
+ # e.g. DELETE /buckets
53
+ bucket_id = obj_id
54
+ bucket_uri = instance_uri(event.request, "bucket", id=bucket_id)
55
+
56
+ if bucket_uri in excluded_resources:
57
+ logger.info(f"History entries for bucket {bucket_uri!r} are disabled in config")
58
+ continue
59
+
60
+ if "collection_id" in payload:
61
+ collection_id = payload["collection_id"]
62
+ collection_uri = instance_uri(
63
+ event.request, "collection", bucket_id=bucket_id, id=collection_id
64
+ )
65
+ if collection_uri in excluded_resources:
66
+ logger.info(
67
+ f"History entries for collection {collection_uri!r} are disabled in config"
68
+ )
69
+ continue
70
+
71
+ # On POST .../records, the URI does not contain the newly created
72
+ # record id.
73
+ parts = event_uri.split("/")
74
+ if resource_name in parts[-1]:
75
+ parts.append(obj_id)
76
+ else:
77
+ # Make sure the id is correct on grouped events.
78
+ parts[-1] = obj_id
79
+ uri = "/".join(parts)
80
+
81
+ if uri in excluded_resources:
82
+ logger.info(f"History entries for record {uri!r} are disabled in config")
83
+ continue
84
+
85
+ targets.append((uri, target))
86
+
87
+ if not targets:
88
+ return # Nothing to do.
89
+
90
+ # Prepare a list of object ids to be fetched from permission backend,
91
+ # and fetch them all at once. Use a mapping for later convenience.
92
+ all_perms_objects_ids = [oid for (oid, _) in targets]
93
+ all_perms_objects_ids.append(bucket_uri)
94
+ if collection_uri is not None:
95
+ all_perms_objects_ids.append(collection_uri)
96
+ all_perms_objects_ids = list(set(all_perms_objects_ids))
97
+ all_permissions = permission.get_objects_permissions(all_perms_objects_ids)
98
+ perms_by_object_id = dict(zip(all_perms_objects_ids, all_permissions))
99
+
100
+ bucket_perms = perms_by_object_id[bucket_uri]
101
+ collection_perms = {}
102
+ if collection_uri is not None:
103
+ collection_perms = perms_by_object_id[collection_uri]
104
+
105
+ # The principals allowed to read the bucket and collection.
106
+ # (Note: ``write`` means ``read``)
107
+ read_principals = set(bucket_perms.get("read", []))
108
+ read_principals.update(bucket_perms.get("write", []))
109
+ read_principals.update(collection_perms.get("read", []))
110
+ read_principals.update(collection_perms.get("write", []))
111
+
112
+ # Create a history entry for each impacted object.
113
+ for uri, target in targets:
114
+ obj_id = target["id"]
115
+ # Prepare the history entry attributes.
116
+ perms = {k: list(v) for k, v in perms_by_object_id[uri].items()}
117
+ eventattrs = dict(**payload)
118
+ eventattrs.pop("timestamp", None) # Already in target `last_modified`.
119
+ eventattrs.pop("bucket_id", None)
120
+ eventattrs[f"{resource_name}_id"] = obj_id
121
+ eventattrs["uri"] = uri
122
+ attrs = dict(
123
+ date=datetime.now(timezone.utc).isoformat(),
124
+ target={"data": target, "permissions": perms},
125
+ **eventattrs,
126
+ )
127
+
128
+ # Create an entry for the 'history' resource, whose parent_id is
129
+ # the bucket URI (c.f. views.py).
130
+ # Note: this will be rolledback if the transaction is rolledback.
131
+ entry = storage.create(parent_id=bucket_uri, resource_name="history", obj=attrs)
132
+
133
+ # If enabled, we trim history by resource.
134
+ # This means that we will only keep the last `auto_trim_max_count` history entries
135
+ # for this same type of object (eg. `collection`, `record`).
136
+ #
137
+ # If trim by user is enabled, we only trim if the user matches the config
138
+ # and we only delete the history entries of this user.
139
+ # This means that if a user touches X different types of objects, we will keep
140
+ # ``(X * auto_trim_max_count)`` entries.
141
+ if is_trim_enabled and (not is_trim_by_user_enabled or user_id in trim_user_ids):
142
+ filters = [
143
+ Filter("resource_name", resource_name, COMPARISON.EQ),
144
+ ]
145
+ if is_trim_by_user_enabled:
146
+ filters.append(Filter("user_id", user_id, COMPARISON.EQ))
147
+
148
+ count_deleted = storage.trim_objects(
149
+ parent_id=bucket_uri,
150
+ resource_name="history",
151
+ filters=filters,
152
+ max_objects=trim_history_max,
153
+ )
154
+ if count_deleted > 0:
155
+ logger.info(f"Trimmed {count_deleted} old history entries.")
156
+ else:
157
+ logger.info(
158
+ f"No old history to trim for {user_id!r} on {resource_name!r} in {bucket_uri!r}."
159
+ )
160
+ else:
161
+ logger.info(
162
+ f"Trimming of old history entries is not enabled{f' for {user_id!r}.' if is_trim_enabled else '.'}"
163
+ )
164
+
165
+ # Without explicit permissions, the ACLs on the history entries will
166
+ # fully depend on the inherited permission tree (eg. bucket:read, bucket:write).
167
+ # This basically means that if user loose the permissions on the related
168
+ # object, they also loose the permission on the history entry.
169
+ # See https://github.com/Kinto/kinto/issues/893
170
+ if not asbool(settings["explicit_permissions"]):
171
+ return
172
+
173
+ # The read permission on the newly created history entry is the union
174
+ # of the object permissions with the one from bucket and collection.
175
+ entry_principals = set(read_principals)
176
+ entry_principals.update(perms.get("read", []))
177
+ entry_principals.update(perms.get("write", []))
178
+ entry_perms = {"read": list(entry_principals)}
179
+ # /buckets/{id}/history is the URI for the list of history entries.
180
+ entry_perm_id = f"/buckets/{bucket_id}/history/{entry['id']}"
181
+ permission.replace_object_permissions(entry_perm_id, entry_perms)
@@ -0,0 +1,66 @@
1
+ import colander
2
+
3
+ from kinto.core import resource
4
+ from kinto.core.resource.viewset import ViewSet
5
+ from kinto.core.storage import Filter
6
+ from kinto.core.utils import instance_uri
7
+
8
+
9
+ class HistorySchema(resource.ResourceSchema):
10
+ user_id = colander.SchemaNode(colander.String())
11
+ uri = colander.SchemaNode(colander.String())
12
+ action = colander.SchemaNode(colander.String())
13
+ date = colander.SchemaNode(colander.String())
14
+ resource_name = colander.SchemaNode(colander.String())
15
+ bucket_id = colander.SchemaNode(colander.String(), missing=colander.drop)
16
+ collection_id = colander.SchemaNode(colander.String(), missing=colander.drop)
17
+ group_id = colander.SchemaNode(colander.String(), missing=colander.drop)
18
+ record_id = colander.SchemaNode(colander.String(), missing=colander.drop)
19
+ target = colander.SchemaNode(colander.Mapping())
20
+
21
+ class Options:
22
+ preserve_unknown = False
23
+
24
+
25
+ # Add custom OpenAPI tags/operation ids
26
+ plural_get_arguments = getattr(ViewSet, "plural_get_arguments", {})
27
+ plural_delete_arguments = getattr(ViewSet, "plural_delete_arguments", {})
28
+
29
+ get_history_arguments = {
30
+ "tags": ["History"],
31
+ "operation_id": "get_history",
32
+ **plural_get_arguments,
33
+ }
34
+ delete_history_arguments = {
35
+ "tags": ["History"],
36
+ "operation_id": "delete_history",
37
+ **plural_delete_arguments,
38
+ }
39
+
40
+
41
+ @resource.register(
42
+ name="history",
43
+ plural_path="/buckets/{{bucket_id}}/history",
44
+ object_path=None,
45
+ plural_methods=("GET", "DELETE"),
46
+ default_arguments={"tags": ["History"], **ViewSet.default_arguments},
47
+ plural_get_arguments=get_history_arguments,
48
+ plural_delete_arguments=delete_history_arguments,
49
+ )
50
+ class History(resource.Resource):
51
+ schema = HistorySchema
52
+
53
+ def get_parent_id(self, request):
54
+ self.bucket_id = request.matchdict["bucket_id"]
55
+ return instance_uri(request, "bucket", id=self.bucket_id)
56
+
57
+ def _extract_filters(self):
58
+ filters = super()._extract_filters()
59
+ filters_str_id = []
60
+ for filt in filters:
61
+ if filt.field in ("record_id", "collection_id", "bucket_id"):
62
+ if isinstance(filt.value, int):
63
+ filt = Filter(filt.field, str(filt.value), filt.operator)
64
+ filters_str_id.append(filt)
65
+
66
+ return filters_str_id