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.
- kinto/__init__.py +92 -0
- kinto/__main__.py +249 -0
- kinto/authorization.py +134 -0
- kinto/config/__init__.py +94 -0
- kinto/config/kinto.tpl +270 -0
- kinto/contribute.json +27 -0
- kinto/core/__init__.py +246 -0
- kinto/core/authentication.py +48 -0
- kinto/core/authorization.py +311 -0
- kinto/core/cache/__init__.py +131 -0
- kinto/core/cache/memcached.py +112 -0
- kinto/core/cache/memory.py +104 -0
- kinto/core/cache/postgresql/__init__.py +178 -0
- kinto/core/cache/postgresql/schema.sql +23 -0
- kinto/core/cache/testing.py +208 -0
- kinto/core/cornice/__init__.py +93 -0
- kinto/core/cornice/cors.py +144 -0
- kinto/core/cornice/errors.py +40 -0
- kinto/core/cornice/pyramidhook.py +373 -0
- kinto/core/cornice/renderer.py +89 -0
- kinto/core/cornice/resource.py +205 -0
- kinto/core/cornice/service.py +641 -0
- kinto/core/cornice/util.py +138 -0
- kinto/core/cornice/validators/__init__.py +94 -0
- kinto/core/cornice/validators/_colander.py +142 -0
- kinto/core/cornice/validators/_marshmallow.py +182 -0
- kinto/core/cornice_swagger/__init__.py +92 -0
- kinto/core/cornice_swagger/converters/__init__.py +21 -0
- kinto/core/cornice_swagger/converters/exceptions.py +6 -0
- kinto/core/cornice_swagger/converters/parameters.py +90 -0
- kinto/core/cornice_swagger/converters/schema.py +249 -0
- kinto/core/cornice_swagger/swagger.py +725 -0
- kinto/core/cornice_swagger/templates/index.html +73 -0
- kinto/core/cornice_swagger/templates/index_script_template.html +21 -0
- kinto/core/cornice_swagger/util.py +42 -0
- kinto/core/cornice_swagger/views.py +78 -0
- kinto/core/decorators.py +74 -0
- kinto/core/errors.py +216 -0
- kinto/core/events.py +301 -0
- kinto/core/initialization.py +738 -0
- kinto/core/listeners/__init__.py +9 -0
- kinto/core/metrics.py +94 -0
- kinto/core/openapi.py +115 -0
- kinto/core/permission/__init__.py +202 -0
- kinto/core/permission/memory.py +167 -0
- kinto/core/permission/postgresql/__init__.py +489 -0
- kinto/core/permission/postgresql/migrations/migration_001_002.sql +18 -0
- kinto/core/permission/postgresql/schema.sql +41 -0
- kinto/core/permission/testing.py +487 -0
- kinto/core/resource/__init__.py +1311 -0
- kinto/core/resource/model.py +412 -0
- kinto/core/resource/schema.py +502 -0
- kinto/core/resource/viewset.py +230 -0
- kinto/core/schema.py +119 -0
- kinto/core/scripts.py +50 -0
- kinto/core/statsd.py +1 -0
- kinto/core/storage/__init__.py +436 -0
- kinto/core/storage/exceptions.py +53 -0
- kinto/core/storage/generators.py +58 -0
- kinto/core/storage/memory.py +651 -0
- kinto/core/storage/postgresql/__init__.py +1131 -0
- kinto/core/storage/postgresql/client.py +120 -0
- kinto/core/storage/postgresql/migrations/migration_001_002.sql +10 -0
- kinto/core/storage/postgresql/migrations/migration_002_003.sql +33 -0
- kinto/core/storage/postgresql/migrations/migration_003_004.sql +18 -0
- kinto/core/storage/postgresql/migrations/migration_004_005.sql +20 -0
- kinto/core/storage/postgresql/migrations/migration_005_006.sql +11 -0
- kinto/core/storage/postgresql/migrations/migration_006_007.sql +74 -0
- kinto/core/storage/postgresql/migrations/migration_007_008.sql +66 -0
- kinto/core/storage/postgresql/migrations/migration_008_009.sql +41 -0
- kinto/core/storage/postgresql/migrations/migration_009_010.sql +98 -0
- kinto/core/storage/postgresql/migrations/migration_010_011.sql +14 -0
- kinto/core/storage/postgresql/migrations/migration_011_012.sql +9 -0
- kinto/core/storage/postgresql/migrations/migration_012_013.sql +71 -0
- kinto/core/storage/postgresql/migrations/migration_013_014.sql +14 -0
- kinto/core/storage/postgresql/migrations/migration_014_015.sql +95 -0
- kinto/core/storage/postgresql/migrations/migration_015_016.sql +4 -0
- kinto/core/storage/postgresql/migrations/migration_016_017.sql +81 -0
- kinto/core/storage/postgresql/migrations/migration_017_018.sql +25 -0
- kinto/core/storage/postgresql/migrations/migration_018_019.sql +8 -0
- kinto/core/storage/postgresql/migrations/migration_019_020.sql +7 -0
- kinto/core/storage/postgresql/migrations/migration_020_021.sql +68 -0
- kinto/core/storage/postgresql/migrations/migration_021_022.sql +62 -0
- kinto/core/storage/postgresql/migrations/migration_022_023.sql +5 -0
- kinto/core/storage/postgresql/migrations/migration_023_024.sql +6 -0
- kinto/core/storage/postgresql/migrations/migration_024_025.sql +6 -0
- kinto/core/storage/postgresql/migrator.py +98 -0
- kinto/core/storage/postgresql/pool.py +55 -0
- kinto/core/storage/postgresql/schema.sql +143 -0
- kinto/core/storage/testing.py +1857 -0
- kinto/core/storage/utils.py +37 -0
- kinto/core/testing.py +182 -0
- kinto/core/utils.py +553 -0
- kinto/core/views/__init__.py +0 -0
- kinto/core/views/batch.py +163 -0
- kinto/core/views/errors.py +145 -0
- kinto/core/views/heartbeat.py +106 -0
- kinto/core/views/hello.py +69 -0
- kinto/core/views/openapi.py +35 -0
- kinto/core/views/version.py +50 -0
- kinto/events.py +3 -0
- kinto/plugins/__init__.py +0 -0
- kinto/plugins/accounts/__init__.py +94 -0
- kinto/plugins/accounts/authentication.py +63 -0
- kinto/plugins/accounts/scripts.py +61 -0
- kinto/plugins/accounts/utils.py +13 -0
- kinto/plugins/accounts/views.py +136 -0
- kinto/plugins/admin/README.md +3 -0
- kinto/plugins/admin/VERSION +1 -0
- kinto/plugins/admin/__init__.py +40 -0
- kinto/plugins/admin/build/VERSION +1 -0
- kinto/plugins/admin/build/assets/index-CYFwtKtL.css +6 -0
- kinto/plugins/admin/build/assets/index-DJ0m93zA.js +149 -0
- kinto/plugins/admin/build/assets/logo-VBRiKSPX.png +0 -0
- kinto/plugins/admin/build/index.html +18 -0
- kinto/plugins/admin/public/help.html +25 -0
- kinto/plugins/admin/views.py +42 -0
- kinto/plugins/default_bucket/__init__.py +191 -0
- kinto/plugins/flush.py +28 -0
- kinto/plugins/history/__init__.py +65 -0
- kinto/plugins/history/listener.py +181 -0
- kinto/plugins/history/views.py +66 -0
- kinto/plugins/openid/__init__.py +131 -0
- kinto/plugins/openid/utils.py +14 -0
- kinto/plugins/openid/views.py +193 -0
- kinto/plugins/prometheus.py +300 -0
- kinto/plugins/statsd.py +85 -0
- kinto/schema_validation.py +135 -0
- kinto/views/__init__.py +34 -0
- kinto/views/admin.py +195 -0
- kinto/views/buckets.py +45 -0
- kinto/views/collections.py +58 -0
- kinto/views/contribute.py +39 -0
- kinto/views/groups.py +90 -0
- kinto/views/permissions.py +235 -0
- kinto/views/records.py +133 -0
- kinto-23.2.1.dist-info/METADATA +232 -0
- kinto-23.2.1.dist-info/RECORD +142 -0
- kinto-23.2.1.dist-info/WHEEL +5 -0
- kinto-23.2.1.dist-info/entry_points.txt +5 -0
- kinto-23.2.1.dist-info/licenses/LICENSE +13 -0
- kinto-23.2.1.dist-info/top_level.txt +1 -0
|
Binary file
|
|
@@ -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
|