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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import colander
|
|
4
|
+
from pyramid import httpexceptions
|
|
5
|
+
from pyramid.security import NO_PERMISSION_REQUIRED
|
|
6
|
+
|
|
7
|
+
from kinto.core import Service, errors
|
|
8
|
+
from kinto.core.cornice.validators import colander_validator
|
|
9
|
+
from kinto.core.errors import ErrorSchema
|
|
10
|
+
from kinto.core.resource.viewset import CONTENT_TYPES
|
|
11
|
+
from kinto.core.utils import build_request, build_response, merge_dicts
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
subrequest_logger = logging.getLogger("subrequest.summary")
|
|
15
|
+
|
|
16
|
+
valid_http_method = colander.OneOf(("GET", "HEAD", "DELETE", "TRACE", "POST", "PUT", "PATCH"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def string_values(node, cstruct):
|
|
20
|
+
"""Validate that a ``colander.Mapping`` only has strings in its values.
|
|
21
|
+
|
|
22
|
+
.. warning::
|
|
23
|
+
|
|
24
|
+
Should be associated to a ``colander.Mapping`` schema node.
|
|
25
|
+
"""
|
|
26
|
+
are_strings = [isinstance(v, str) for v in cstruct.values()]
|
|
27
|
+
if not all(are_strings):
|
|
28
|
+
error_msg = f"{cstruct} contains non string value"
|
|
29
|
+
raise colander.Invalid(node, error_msg)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BatchRequestSchema(colander.MappingSchema):
|
|
33
|
+
method = colander.SchemaNode(
|
|
34
|
+
colander.String(), validator=valid_http_method, missing=colander.drop
|
|
35
|
+
)
|
|
36
|
+
path = colander.SchemaNode(colander.String(), validator=colander.Regex("^/"))
|
|
37
|
+
headers = colander.SchemaNode(
|
|
38
|
+
colander.Mapping(unknown="preserve"), validator=string_values, missing=colander.drop
|
|
39
|
+
)
|
|
40
|
+
body = colander.SchemaNode(colander.Mapping(unknown="preserve"), missing=colander.drop)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def schema_type():
|
|
44
|
+
return colander.Mapping(unknown="raise")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BatchPayloadSchema(colander.MappingSchema):
|
|
48
|
+
defaults = BatchRequestSchema(missing=colander.drop).clone()
|
|
49
|
+
requests = colander.SchemaNode(colander.Sequence(), BatchRequestSchema())
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def schema_type():
|
|
53
|
+
return colander.Mapping(unknown="raise")
|
|
54
|
+
|
|
55
|
+
def __init__(self, *args, **kwargs):
|
|
56
|
+
super().__init__(*args, **kwargs)
|
|
57
|
+
# On defaults, path is not mandatory.
|
|
58
|
+
self.get("defaults").get("path").missing = colander.drop
|
|
59
|
+
|
|
60
|
+
def deserialize(self, cstruct=colander.null):
|
|
61
|
+
"""Preprocess received data to carefully merge defaults."""
|
|
62
|
+
if cstruct is not colander.null:
|
|
63
|
+
defaults = cstruct.get("defaults")
|
|
64
|
+
requests = cstruct.get("requests")
|
|
65
|
+
if isinstance(defaults, dict) and isinstance(requests, list):
|
|
66
|
+
for request in requests:
|
|
67
|
+
if isinstance(request, dict):
|
|
68
|
+
merge_dicts(request, defaults)
|
|
69
|
+
return super().deserialize(cstruct)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BatchRequest(colander.MappingSchema):
|
|
73
|
+
body = BatchPayloadSchema()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BatchResponseSchema(colander.MappingSchema):
|
|
77
|
+
status = colander.SchemaNode(colander.Integer())
|
|
78
|
+
path = colander.SchemaNode(colander.String())
|
|
79
|
+
headers = colander.SchemaNode(
|
|
80
|
+
colander.Mapping(unknown="preserve"), validator=string_values, missing=colander.drop
|
|
81
|
+
)
|
|
82
|
+
body = colander.SchemaNode(colander.Mapping(unknown="preserve"), missing=colander.drop)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BatchResponseBodySchema(colander.MappingSchema):
|
|
86
|
+
responses = colander.SequenceSchema(BatchResponseSchema(missing=colander.drop))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class BatchResponse(colander.MappingSchema):
|
|
90
|
+
body = BatchResponseBodySchema()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ErrorResponseSchema(colander.MappingSchema):
|
|
94
|
+
body = ErrorSchema()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
batch_responses = {
|
|
98
|
+
"200": BatchResponse(description="Return a list of operation responses."),
|
|
99
|
+
"400": ErrorResponseSchema(description="The request was badly formatted."),
|
|
100
|
+
"default": ErrorResponseSchema(description="an unknown error occurred."),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
batch = Service(name="batch", path="/batch", description="Batch operations")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@batch.post(
|
|
107
|
+
schema=BatchRequest(),
|
|
108
|
+
validators=(colander_validator,),
|
|
109
|
+
content_type=CONTENT_TYPES,
|
|
110
|
+
permission=NO_PERMISSION_REQUIRED,
|
|
111
|
+
tags=["Batch"],
|
|
112
|
+
operation_id="batch",
|
|
113
|
+
response_schemas=batch_responses,
|
|
114
|
+
)
|
|
115
|
+
def post_batch(request):
|
|
116
|
+
requests = request.validated["body"]["requests"]
|
|
117
|
+
|
|
118
|
+
request.log_context(batch_size=len(requests))
|
|
119
|
+
|
|
120
|
+
limit = request.registry.settings["batch_max_requests"]
|
|
121
|
+
if limit and len(requests) > int(limit):
|
|
122
|
+
error_msg = f"Number of requests is limited to {limit}"
|
|
123
|
+
request.errors.add("body", "requests", error_msg)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if any([batch.path in req["path"] for req in requests]):
|
|
127
|
+
error_msg = f"Recursive call on {batch.path} endpoint is forbidden."
|
|
128
|
+
request.errors.add("body", "requests", error_msg)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
responses = []
|
|
132
|
+
|
|
133
|
+
for subrequest_spec in requests:
|
|
134
|
+
subrequest = build_request(request, subrequest_spec)
|
|
135
|
+
|
|
136
|
+
log_context = {
|
|
137
|
+
**request.log_context(),
|
|
138
|
+
"path": subrequest.path,
|
|
139
|
+
"method": subrequest.method,
|
|
140
|
+
}
|
|
141
|
+
try:
|
|
142
|
+
# Invoke subrequest without individual transaction.
|
|
143
|
+
resp, subrequest = request.follow_subrequest(subrequest, use_tweens=False)
|
|
144
|
+
except httpexceptions.HTTPException as e:
|
|
145
|
+
# Since some request in the batch failed, we need to stop the parent request
|
|
146
|
+
# through Pyramid's transaction manager. 5XX errors are already caught by
|
|
147
|
+
# pyramid_tm's commit_veto
|
|
148
|
+
# https://github.com/Kinto/kinto/issues/624
|
|
149
|
+
if e.status_code == 409:
|
|
150
|
+
request.tm.abort()
|
|
151
|
+
|
|
152
|
+
if e.content_type == "application/json":
|
|
153
|
+
resp = e
|
|
154
|
+
else:
|
|
155
|
+
# JSONify raw Pyramid errors.
|
|
156
|
+
resp = errors.http_error(e)
|
|
157
|
+
|
|
158
|
+
subrequest_logger.info("subrequest.summary", extra=log_context)
|
|
159
|
+
|
|
160
|
+
dict_resp = build_response(resp, subrequest)
|
|
161
|
+
responses.append(dict_resp)
|
|
162
|
+
|
|
163
|
+
return {"responses": responses}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from pyramid import httpexceptions
|
|
4
|
+
from pyramid.authorization import Authenticated
|
|
5
|
+
from pyramid.httpexceptions import HTTPTemporaryRedirect
|
|
6
|
+
from pyramid.security import NO_PERMISSION_REQUIRED, forget
|
|
7
|
+
from pyramid.settings import asbool
|
|
8
|
+
from pyramid.view import view_config
|
|
9
|
+
|
|
10
|
+
from kinto.core.errors import ERRORS, http_error, request_GET
|
|
11
|
+
from kinto.core.storage import exceptions as storage_exceptions
|
|
12
|
+
from kinto.core.utils import reapply_cors
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@view_config(context=httpexceptions.HTTPForbidden, permission=NO_PERMISSION_REQUIRED)
|
|
19
|
+
def authorization_required(response, request):
|
|
20
|
+
"""Distinguish authentication required (``401 Unauthorized``) from
|
|
21
|
+
not allowed (``403 Forbidden``).
|
|
22
|
+
"""
|
|
23
|
+
if Authenticated not in request.effective_principals:
|
|
24
|
+
if response.content_type != "application/json":
|
|
25
|
+
# This is always the case when `HTTPForbidden` is raised by Pyramid
|
|
26
|
+
# on protected views with unauthenticated requests.
|
|
27
|
+
error_msg = "Please authenticate yourself to use this endpoint."
|
|
28
|
+
response = http_error(
|
|
29
|
+
httpexceptions.HTTPUnauthorized(),
|
|
30
|
+
errno=ERRORS.MISSING_AUTH_TOKEN,
|
|
31
|
+
message=error_msg,
|
|
32
|
+
)
|
|
33
|
+
response.headers.extend(forget(request))
|
|
34
|
+
return response
|
|
35
|
+
|
|
36
|
+
if response.content_type != "application/json":
|
|
37
|
+
error_msg = "This user cannot access this resource."
|
|
38
|
+
response = http_error(
|
|
39
|
+
httpexceptions.HTTPForbidden(), errno=ERRORS.FORBIDDEN, message=error_msg
|
|
40
|
+
)
|
|
41
|
+
return reapply_cors(request, response)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@view_config(context=httpexceptions.HTTPNotFound, permission=NO_PERMISSION_REQUIRED)
|
|
45
|
+
def page_not_found(response, request):
|
|
46
|
+
"""Return a JSON 404 error response."""
|
|
47
|
+
config_key = "trailing_slash_redirect_enabled"
|
|
48
|
+
redirect_enabled = request.registry.settings[config_key]
|
|
49
|
+
trailing_slash_redirection_enabled = asbool(redirect_enabled)
|
|
50
|
+
|
|
51
|
+
querystring = request.url[(request.url.rindex(request.path) + len(request.path)) :]
|
|
52
|
+
|
|
53
|
+
errno = ERRORS.MISSING_RESOURCE
|
|
54
|
+
error_msg = "The resource you are looking for could not be found."
|
|
55
|
+
|
|
56
|
+
if not request.path.startswith(f"/{request.registry.route_prefix}"):
|
|
57
|
+
errno = ERRORS.VERSION_NOT_AVAILABLE
|
|
58
|
+
error_msg = "The requested API version is not available on this server."
|
|
59
|
+
elif trailing_slash_redirection_enabled:
|
|
60
|
+
redirect = None
|
|
61
|
+
|
|
62
|
+
if request.path.endswith("/"):
|
|
63
|
+
path = request.path.rstrip("/")
|
|
64
|
+
redirect = f"{path}{querystring}"
|
|
65
|
+
elif request.path == f"/{request.registry.route_prefix}":
|
|
66
|
+
# Case for /v0 -> /v0/
|
|
67
|
+
redirect = f"/{request.registry.route_prefix}/{querystring}"
|
|
68
|
+
|
|
69
|
+
if redirect:
|
|
70
|
+
resp = HTTPTemporaryRedirect(redirect)
|
|
71
|
+
cache_seconds = int(request.registry.settings["trailing_slash_redirect_ttl_seconds"])
|
|
72
|
+
if cache_seconds >= 0:
|
|
73
|
+
resp.cache_expires(cache_seconds)
|
|
74
|
+
return reapply_cors(request, resp)
|
|
75
|
+
|
|
76
|
+
if response.content_type != "application/json":
|
|
77
|
+
response = http_error(httpexceptions.HTTPNotFound(), errno=errno, message=error_msg)
|
|
78
|
+
return reapply_cors(request, response)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@view_config(context=httpexceptions.HTTPServiceUnavailable, permission=NO_PERMISSION_REQUIRED)
|
|
82
|
+
def service_unavailable(response, request):
|
|
83
|
+
if response.content_type != "application/json":
|
|
84
|
+
error_msg = (
|
|
85
|
+
"Service temporary unavailable due to overloading or maintenance, please retry later."
|
|
86
|
+
)
|
|
87
|
+
response = http_error(response, errno=ERRORS.BACKEND, message=error_msg)
|
|
88
|
+
|
|
89
|
+
retry_after = request.registry.settings["retry_after_seconds"]
|
|
90
|
+
response.headers["Retry-After"] = str(retry_after)
|
|
91
|
+
return reapply_cors(request, response)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@view_config(context=httpexceptions.HTTPMethodNotAllowed, permission=NO_PERMISSION_REQUIRED)
|
|
95
|
+
def method_not_allowed(context, request):
|
|
96
|
+
if context.content_type == "application/json":
|
|
97
|
+
return context
|
|
98
|
+
|
|
99
|
+
response = http_error(
|
|
100
|
+
context, errno=ERRORS.METHOD_NOT_ALLOWED, message="Method not allowed on this endpoint."
|
|
101
|
+
)
|
|
102
|
+
return reapply_cors(request, response)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@view_config(context=Exception, permission=NO_PERMISSION_REQUIRED)
|
|
106
|
+
@view_config(context=httpexceptions.HTTPException, permission=NO_PERMISSION_REQUIRED)
|
|
107
|
+
def error(context, request):
|
|
108
|
+
"""Catch server errors and trace them."""
|
|
109
|
+
if isinstance(context, httpexceptions.Response):
|
|
110
|
+
return reapply_cors(request, context)
|
|
111
|
+
|
|
112
|
+
if isinstance(context, storage_exceptions.IntegrityError):
|
|
113
|
+
error_msg = "Integrity constraint violated, please retry."
|
|
114
|
+
response = http_error(
|
|
115
|
+
httpexceptions.HTTPConflict(), errno=ERRORS.CONSTRAINT_VIOLATED, message=error_msg
|
|
116
|
+
)
|
|
117
|
+
retry_after = request.registry.settings["retry_after_seconds"]
|
|
118
|
+
response.headers["Retry-After"] = str(retry_after)
|
|
119
|
+
return reapply_cors(request, response)
|
|
120
|
+
|
|
121
|
+
# Log some information about current request.
|
|
122
|
+
extra = {"path": request.path, "method": request.method}
|
|
123
|
+
qs = dict(request_GET(request))
|
|
124
|
+
if qs:
|
|
125
|
+
extra["querystring"] = qs
|
|
126
|
+
# Take errno from original exception, or undefined if unknown/unhandled.
|
|
127
|
+
try:
|
|
128
|
+
extra["errno"] = context.errno.value
|
|
129
|
+
except AttributeError:
|
|
130
|
+
extra["errno"] = ERRORS.UNDEFINED.value
|
|
131
|
+
|
|
132
|
+
if isinstance(context, storage_exceptions.BackendError):
|
|
133
|
+
logger.critical(context.original, extra=extra, exc_info=context)
|
|
134
|
+
response = httpexceptions.HTTPServiceUnavailable()
|
|
135
|
+
return service_unavailable(response, request)
|
|
136
|
+
|
|
137
|
+
# Within the exception view, sys.exc_info() will return null.
|
|
138
|
+
# see https://github.com/python/cpython/blob/ce9e62544/Lib/logging/__init__.py#L1460-L1462
|
|
139
|
+
logger.error(context, extra=extra, exc_info=context)
|
|
140
|
+
|
|
141
|
+
error_msg = "A programmatic error occured, developers have been informed."
|
|
142
|
+
info = request.registry.settings["error_info_link"]
|
|
143
|
+
response = http_error(httpexceptions.HTTPInternalServerError(), message=error_msg, info=info)
|
|
144
|
+
|
|
145
|
+
return reapply_cors(request, response)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor, wait
|
|
3
|
+
|
|
4
|
+
import colander
|
|
5
|
+
import transaction
|
|
6
|
+
from pyramid.security import NO_PERMISSION_REQUIRED
|
|
7
|
+
|
|
8
|
+
from kinto.core import Service
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
heartbeat = Service(name="heartbeat", path="/__heartbeat__", description="Server health")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HeartbeatResponseSchema(colander.MappingSchema):
|
|
18
|
+
body = colander.SchemaNode(colander.Mapping(unknown="preserve"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
heartbeat_responses = {
|
|
22
|
+
"200": HeartbeatResponseSchema(description="Server is working properly."),
|
|
23
|
+
"503": HeartbeatResponseSchema(description="One or more subsystems failing."),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@heartbeat.get(
|
|
28
|
+
permission=NO_PERMISSION_REQUIRED,
|
|
29
|
+
tags=["Utilities"],
|
|
30
|
+
operation_id="__heartbeat__",
|
|
31
|
+
response_schemas=heartbeat_responses,
|
|
32
|
+
)
|
|
33
|
+
def get_heartbeat(request):
|
|
34
|
+
"""Return information about server health."""
|
|
35
|
+
status = {}
|
|
36
|
+
|
|
37
|
+
def heartbeat_check(name, func):
|
|
38
|
+
status[name] = False
|
|
39
|
+
status[name] = func(request)
|
|
40
|
+
# Since the heartbeat checks run concurrently, their transactions
|
|
41
|
+
# overlap and might end in shared lock errors. By aborting here
|
|
42
|
+
# we clean-up the state on each heartbeat call instead of once at the
|
|
43
|
+
# end of the request. See bug Kinto/kinto#804
|
|
44
|
+
transaction.abort()
|
|
45
|
+
|
|
46
|
+
# Start executing heartbeats concurrently.
|
|
47
|
+
heartbeats = request.registry.heartbeats
|
|
48
|
+
pool = ThreadPoolExecutor(max_workers=max(1, len(heartbeats.keys())))
|
|
49
|
+
futures = []
|
|
50
|
+
for name, func in heartbeats.items():
|
|
51
|
+
future = pool.submit(heartbeat_check, name, func)
|
|
52
|
+
future.__heartbeat_name = name # For logging purposes.
|
|
53
|
+
futures.append(future)
|
|
54
|
+
|
|
55
|
+
# Wait for the results, with timeout.
|
|
56
|
+
seconds = float(request.registry.settings["heartbeat_timeout_seconds"])
|
|
57
|
+
done, not_done = wait(futures, timeout=seconds)
|
|
58
|
+
|
|
59
|
+
# A heartbeat is supposed to return True or False, and never raise.
|
|
60
|
+
# Just in case, go though results to spot any potential exception.
|
|
61
|
+
for future in done:
|
|
62
|
+
exc = future.exception()
|
|
63
|
+
if exc is not None:
|
|
64
|
+
logger.error(f"'{future.__heartbeat_name}' heartbeat failed.")
|
|
65
|
+
logger.error(exc)
|
|
66
|
+
|
|
67
|
+
# Log timed-out heartbeats.
|
|
68
|
+
for future in not_done:
|
|
69
|
+
name = future.__heartbeat_name
|
|
70
|
+
error_msg = f"'{name}' heartbeat has exceeded timeout of {seconds} seconds."
|
|
71
|
+
logger.error(error_msg)
|
|
72
|
+
|
|
73
|
+
# If any has failed, return a 503 error response.
|
|
74
|
+
has_error = not all([v or v is None for v in status.values()])
|
|
75
|
+
if has_error:
|
|
76
|
+
request.response.status = 503
|
|
77
|
+
|
|
78
|
+
return status
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LbHeartbeatResponseSchema(colander.MappingSchema):
|
|
82
|
+
body = colander.SchemaNode(colander.Mapping())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
lbheartbeat_responses = {
|
|
86
|
+
"200": LbHeartbeatResponseSchema(description="Returned if server is reachable.")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
lbheartbeat = Service(name="lbheartbeat", path="/__lbheartbeat__", description="Web head health")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@lbheartbeat.get(
|
|
94
|
+
permission=NO_PERMISSION_REQUIRED,
|
|
95
|
+
tags=["Utilities"],
|
|
96
|
+
operation_id="__lbheartbeat__",
|
|
97
|
+
response_schemas=lbheartbeat_responses,
|
|
98
|
+
)
|
|
99
|
+
def get_lbheartbeat(request):
|
|
100
|
+
"""Return successful healthy response.
|
|
101
|
+
|
|
102
|
+
If the load-balancer tries to access this URL and fails, this means the
|
|
103
|
+
Web head is not operational and should be dropped.
|
|
104
|
+
"""
|
|
105
|
+
status = {}
|
|
106
|
+
return status
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import colander
|
|
2
|
+
from pyramid.authorization import Authenticated
|
|
3
|
+
from pyramid.security import NO_PERMISSION_REQUIRED
|
|
4
|
+
|
|
5
|
+
from kinto.config import config_attributes
|
|
6
|
+
from kinto.core import Service
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
hello = Service(name="hello", path="/", description="Welcome")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HelloResponseSchema(colander.MappingSchema):
|
|
13
|
+
body = colander.SchemaNode(colander.Mapping(unknown="preserve"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
hello_response_schemas = {
|
|
17
|
+
"200": HelloResponseSchema(description="Return information about the running Instance.")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@hello.get(
|
|
22
|
+
permission=NO_PERMISSION_REQUIRED,
|
|
23
|
+
tags=["Utilities"],
|
|
24
|
+
operation_id="server_info",
|
|
25
|
+
response_schemas=hello_response_schemas,
|
|
26
|
+
)
|
|
27
|
+
def get_hello(request):
|
|
28
|
+
"""Return information regarding the current instance."""
|
|
29
|
+
settings = request.registry.settings
|
|
30
|
+
|
|
31
|
+
project_name = settings["project_name"]
|
|
32
|
+
project_version = settings["project_version"]
|
|
33
|
+
|
|
34
|
+
data = dict(
|
|
35
|
+
project_name=project_name,
|
|
36
|
+
project_version=project_version,
|
|
37
|
+
http_api_version=settings["http_api_version"],
|
|
38
|
+
project_docs=settings["project_docs"],
|
|
39
|
+
url=request.route_url(hello.name),
|
|
40
|
+
config=config_attributes(),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
eos = get_eos(request)
|
|
44
|
+
if eos:
|
|
45
|
+
data["eos"] = eos
|
|
46
|
+
|
|
47
|
+
data["settings"] = {}
|
|
48
|
+
public_settings = request.registry.public_settings
|
|
49
|
+
for setting in list(public_settings):
|
|
50
|
+
data["settings"][setting] = settings[setting]
|
|
51
|
+
|
|
52
|
+
# If current user is authenticated, add user info:
|
|
53
|
+
# (Note: this will call authenticated_userid() with multiauth+groupfinder)
|
|
54
|
+
if Authenticated in request.effective_principals:
|
|
55
|
+
data["user"] = request.get_user_info()
|
|
56
|
+
|
|
57
|
+
if settings["readonly"]:
|
|
58
|
+
# Information can be cached.
|
|
59
|
+
cache_seconds = int(settings["root_cache_expires_seconds"])
|
|
60
|
+
request.response.cache_expires(cache_seconds)
|
|
61
|
+
|
|
62
|
+
# Application can register and expose arbitrary capabilities.
|
|
63
|
+
data["capabilities"] = request.registry.api_capabilities
|
|
64
|
+
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_eos(request):
|
|
69
|
+
return request.registry.settings["eos"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import colander
|
|
2
|
+
from pyramid.security import NO_PERMISSION_REQUIRED
|
|
3
|
+
|
|
4
|
+
from kinto.core import Service
|
|
5
|
+
from kinto.core.cornice.service import get_services
|
|
6
|
+
from kinto.core.openapi import OpenAPI
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
openapi = Service(name="openapi", path="/__api__", description="OpenAPI description")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenAPIResponseSchema(colander.MappingSchema):
|
|
13
|
+
body = colander.SchemaNode(colander.Mapping(unknown="preserve"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
openapi_response_schemas = {
|
|
17
|
+
"200": OpenAPIResponseSchema(
|
|
18
|
+
description="Return an OpenAPI description of the running instance."
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@openapi.get(
|
|
24
|
+
permission=NO_PERMISSION_REQUIRED,
|
|
25
|
+
response_schemas=openapi_response_schemas,
|
|
26
|
+
tags=["Utilities"],
|
|
27
|
+
operation_id="get_openapi_spec",
|
|
28
|
+
)
|
|
29
|
+
def openapi_view(request):
|
|
30
|
+
# Only build json once
|
|
31
|
+
try:
|
|
32
|
+
return openapi_view.__json__
|
|
33
|
+
except AttributeError:
|
|
34
|
+
openapi_view.__json__ = OpenAPI(get_services(), request).generate()
|
|
35
|
+
return openapi_view.__json__
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import colander
|
|
4
|
+
from pyramid.security import NO_PERMISSION_REQUIRED
|
|
5
|
+
|
|
6
|
+
from kinto.core import Service
|
|
7
|
+
from kinto.core.utils import json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
HERE = os.path.dirname(__file__)
|
|
11
|
+
ORIGIN = os.path.dirname(HERE)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VersionResponseSchema(colander.MappingSchema):
|
|
15
|
+
body = colander.SchemaNode(colander.Mapping(unknown="preserve"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
version_response_schemas = {
|
|
19
|
+
"200": VersionResponseSchema(description="Return the running Instance version information.")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
version = Service(name="version", path="/__version__", description="Version")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@version.get(
|
|
27
|
+
permission=NO_PERMISSION_REQUIRED,
|
|
28
|
+
tags=["Utilities"],
|
|
29
|
+
operation_id="__version__",
|
|
30
|
+
response_schemas=version_response_schemas,
|
|
31
|
+
)
|
|
32
|
+
def version_view(request):
|
|
33
|
+
try:
|
|
34
|
+
return version_view.__json__
|
|
35
|
+
except AttributeError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
location = request.registry.settings["version_json_path"]
|
|
39
|
+
files = [
|
|
40
|
+
location, # Default is current working dir.
|
|
41
|
+
os.path.join(ORIGIN, "version.json"), # Relative to the package root.
|
|
42
|
+
os.path.join(HERE, "version.json"), # Relative to this file.
|
|
43
|
+
]
|
|
44
|
+
for version_file in files:
|
|
45
|
+
if os.path.exists(version_file):
|
|
46
|
+
with open(version_file) as f:
|
|
47
|
+
version_view.__json__ = json.load(f)
|
|
48
|
+
return version_view.__json__ # First one wins.
|
|
49
|
+
|
|
50
|
+
raise FileNotFoundError("Version file missing from {}".format(",".join(files)))
|
kinto/events.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from pyramid.exceptions import ConfigurationError
|
|
5
|
+
|
|
6
|
+
from kinto.authorization import PERMISSIONS_INHERITANCE_TREE
|
|
7
|
+
|
|
8
|
+
from .authentication import AccountsAuthenticationPolicy as AccountsPolicy
|
|
9
|
+
from .utils import ACCOUNT_CACHE_KEY, ACCOUNT_POLICY_NAME
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ACCOUNT_CACHE_KEY",
|
|
14
|
+
"ACCOUNT_POLICY_NAME",
|
|
15
|
+
"AccountsPolicy",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
DOCS_URL = "https://kinto.readthedocs.io/en/stable/api/1.x/accounts.html"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def includeme(config):
|
|
22
|
+
settings = config.get_settings()
|
|
23
|
+
config.add_api_capability(
|
|
24
|
+
"accounts",
|
|
25
|
+
description="Manage user accounts.",
|
|
26
|
+
url="https://kinto.readthedocs.io/en/latest/api/1.x/accounts.html",
|
|
27
|
+
validation_enabled=False,
|
|
28
|
+
)
|
|
29
|
+
kwargs = {}
|
|
30
|
+
config.scan("kinto.plugins.accounts.views", **kwargs)
|
|
31
|
+
|
|
32
|
+
PERMISSIONS_INHERITANCE_TREE["root"].update({"account:create": {}})
|
|
33
|
+
PERMISSIONS_INHERITANCE_TREE["account"] = {
|
|
34
|
+
"write": {"account": ["write"]},
|
|
35
|
+
"read": {"account": ["write", "read"]},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Check that the account policy is mentioned in config if included.
|
|
39
|
+
accountClass = "AccountsPolicy"
|
|
40
|
+
policy = None
|
|
41
|
+
for k, v in settings.items():
|
|
42
|
+
m = re.match("multiauth\\.policy\\.(.*)\\.use", k)
|
|
43
|
+
if m:
|
|
44
|
+
if v.endswith(accountClass) or v.endswith("AccountsAuthenticationPolicy"):
|
|
45
|
+
policy = m.group(1)
|
|
46
|
+
|
|
47
|
+
if settings["storage_backend"] == "kinto.core.storage.memory": # pragma: no cover
|
|
48
|
+
error_msg = (
|
|
49
|
+
"\033[1;31;40m"
|
|
50
|
+
"The account plugin works really poorly with the memory backend because "
|
|
51
|
+
"accounts are flushed at each startup.\nThis is why you can't use the "
|
|
52
|
+
"kinto create-user command with it."
|
|
53
|
+
"\033[0;37;40m"
|
|
54
|
+
)
|
|
55
|
+
print(error_msg, file=sys.stderr)
|
|
56
|
+
|
|
57
|
+
if not policy:
|
|
58
|
+
error_msg = (
|
|
59
|
+
"Account policy missing the 'multiauth.policy.*.use' "
|
|
60
|
+
f"setting. See {accountClass} in docs {DOCS_URL}."
|
|
61
|
+
)
|
|
62
|
+
raise ConfigurationError(error_msg)
|
|
63
|
+
|
|
64
|
+
# Add some safety to avoid weird behaviour with basicauth default policy.
|
|
65
|
+
auth_policies = settings["multiauth.policies"]
|
|
66
|
+
if "basicauth" in auth_policies and policy in auth_policies:
|
|
67
|
+
if auth_policies.index("basicauth") < auth_policies.index(policy):
|
|
68
|
+
error_msg = (
|
|
69
|
+
"'basicauth' should not be mentioned before '%s' in 'multiauth.policies' setting."
|
|
70
|
+
) % policy
|
|
71
|
+
raise ConfigurationError(error_msg)
|
|
72
|
+
|
|
73
|
+
# We assume anyone in account_create_principals is to create
|
|
74
|
+
# accounts for other people.
|
|
75
|
+
# No one can create accounts for other people unless they are an
|
|
76
|
+
# "admin", defined as someone matching account_write_principals.
|
|
77
|
+
# Therefore any account that is in account_create_principals
|
|
78
|
+
# should be in account_write_principals too.
|
|
79
|
+
creators = set(settings.get("account_create_principals", "").split())
|
|
80
|
+
admins = set(settings.get("account_write_principals", "").split())
|
|
81
|
+
cant_create_anything = creators.difference(admins)
|
|
82
|
+
# system.Everyone isn't an account.
|
|
83
|
+
cant_create_anything.discard("system.Everyone")
|
|
84
|
+
if cant_create_anything:
|
|
85
|
+
message = (
|
|
86
|
+
"Configuration has some principals in account_create_principals "
|
|
87
|
+
"but not in account_write_principals. These principals will only be "
|
|
88
|
+
"able to create their own accounts. This may not be what you want.\n"
|
|
89
|
+
"If you want these users to be able to create accounts for other users, "
|
|
90
|
+
"add them to account_write_principals.\n"
|
|
91
|
+
f"Affected users: {list(cant_create_anything)}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
raise ConfigurationError(message)
|