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,131 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from pyramid import authentication as base_auth
|
|
3
|
+
from pyramid.interfaces import IAuthenticationPolicy
|
|
4
|
+
from pyramid.settings import aslist
|
|
5
|
+
from zope.interface import implementer
|
|
6
|
+
|
|
7
|
+
from kinto.core import logger
|
|
8
|
+
from kinto.core import utils as core_utils
|
|
9
|
+
from kinto.core.openapi import OpenAPI
|
|
10
|
+
|
|
11
|
+
from .utils import fetch_openid_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@implementer(IAuthenticationPolicy)
|
|
15
|
+
class OpenIDConnectPolicy(base_auth.CallbackAuthenticationPolicy):
|
|
16
|
+
def __init__(self, issuer, client_id, realm="Realm", **kwargs):
|
|
17
|
+
self.realm = realm
|
|
18
|
+
self.issuer = issuer
|
|
19
|
+
self.client_id = client_id
|
|
20
|
+
self.client_secret = kwargs.get("client_secret", "")
|
|
21
|
+
self.header_type = kwargs.get("header_type", "Bearer")
|
|
22
|
+
self.userid_field = kwargs.get("userid_field", "sub")
|
|
23
|
+
self.verification_ttl = int(kwargs.get("verification_ttl_seconds", 86400))
|
|
24
|
+
|
|
25
|
+
# Fetch OpenID config (at instantiation, ie. startup)
|
|
26
|
+
self.oid_config = fetch_openid_config(issuer)
|
|
27
|
+
|
|
28
|
+
self._jwt_keys = None
|
|
29
|
+
|
|
30
|
+
def unauthenticated_userid(self, request):
|
|
31
|
+
"""Return the userid or ``None`` if token could not be verified."""
|
|
32
|
+
settings = request.registry.settings
|
|
33
|
+
hmac_secret = settings["userid_hmac_secret"]
|
|
34
|
+
|
|
35
|
+
authorization = request.headers.get("Authorization", "")
|
|
36
|
+
try:
|
|
37
|
+
authmeth, access_token = authorization.split(" ", 1)
|
|
38
|
+
except ValueError:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
if authmeth.lower() != self.header_type.lower():
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
# XXX JWT Access token
|
|
45
|
+
# https://auth0.com/docs/tokens/access-token#access-token-format
|
|
46
|
+
|
|
47
|
+
# Check cache if these tokens were already verified.
|
|
48
|
+
hmac_tokens = core_utils.hmac_digest(hmac_secret, access_token)
|
|
49
|
+
cache_key = f"openid:verify:{hmac_tokens}"
|
|
50
|
+
payload = request.registry.cache.get(cache_key)
|
|
51
|
+
if payload is None:
|
|
52
|
+
# This can take some time.
|
|
53
|
+
payload = self._verify_token(access_token)
|
|
54
|
+
if payload is None:
|
|
55
|
+
return None
|
|
56
|
+
# Save for next time / refresh ttl.
|
|
57
|
+
request.registry.cache.set(cache_key, payload, ttl=self.verification_ttl)
|
|
58
|
+
request.bound_data["user_profile"] = payload
|
|
59
|
+
# Extract meaningful field from userinfo (eg. email or sub)
|
|
60
|
+
return payload.get(self.userid_field)
|
|
61
|
+
|
|
62
|
+
def forget(self, request):
|
|
63
|
+
"""A no-op. Credentials are sent on every request.
|
|
64
|
+
Return WWW-Authenticate Realm header for Bearer token.
|
|
65
|
+
"""
|
|
66
|
+
return [("WWW-Authenticate", '%s realm="%s"' % (self.header_type, self.realm))]
|
|
67
|
+
|
|
68
|
+
def _verify_token(self, access_token):
|
|
69
|
+
uri = self.oid_config["userinfo_endpoint"]
|
|
70
|
+
# Opaque access token string. Fetch user info from profile.
|
|
71
|
+
try:
|
|
72
|
+
resp = requests.get(uri, headers={"Authorization": "Bearer " + access_token})
|
|
73
|
+
resp.raise_for_status()
|
|
74
|
+
userprofile = resp.json()
|
|
75
|
+
return userprofile
|
|
76
|
+
|
|
77
|
+
except (requests.exceptions.HTTPError, ValueError, KeyError) as e:
|
|
78
|
+
logger.debug("Unable to fetch user profile from %s (%s)" % (uri, e))
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_user_profile(request):
|
|
83
|
+
return request.bound_data.get("user_profile", {})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def includeme(config):
|
|
87
|
+
# Activate end-points.
|
|
88
|
+
config.scan("kinto.plugins.openid.views")
|
|
89
|
+
|
|
90
|
+
settings = config.get_settings()
|
|
91
|
+
|
|
92
|
+
openid_policies = []
|
|
93
|
+
for policy in aslist(settings["multiauth.policies"]):
|
|
94
|
+
v = settings.get("multiauth.policy.%s.use" % policy, "")
|
|
95
|
+
if v.endswith("OpenIDConnectPolicy"):
|
|
96
|
+
openid_policies.append(policy)
|
|
97
|
+
|
|
98
|
+
if len(openid_policies) == 0:
|
|
99
|
+
# Do not add the capability if no policy is configured.
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
providers_infos = []
|
|
103
|
+
for name in openid_policies:
|
|
104
|
+
issuer = settings["multiauth.policy.%s.issuer" % name]
|
|
105
|
+
openid_config = fetch_openid_config(issuer)
|
|
106
|
+
|
|
107
|
+
client_id = settings["multiauth.policy.%s.client_id" % name]
|
|
108
|
+
header_type = settings.get("multiauth.policy.%s.header_type", "Bearer")
|
|
109
|
+
|
|
110
|
+
providers_infos.append(
|
|
111
|
+
{
|
|
112
|
+
"name": name,
|
|
113
|
+
"issuer": openid_config["issuer"],
|
|
114
|
+
"auth_path": "/openid/%s/login" % name,
|
|
115
|
+
"client_id": client_id,
|
|
116
|
+
"header_type": header_type,
|
|
117
|
+
"userinfo_endpoint": openid_config["userinfo_endpoint"],
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
OpenAPI.expose_authentication_method(
|
|
122
|
+
name, {"type": "oauth2", "authorizationUrl": openid_config["authorization_endpoint"]}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
config.add_api_capability(
|
|
126
|
+
"openid",
|
|
127
|
+
description="OpenID connect support.",
|
|
128
|
+
url="http://kinto.readthedocs.io/en/stable/api/1.x/authentication.html",
|
|
129
|
+
providers=providers_infos,
|
|
130
|
+
)
|
|
131
|
+
config.add_request_method(get_user_profile, name="get_user_profile")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
_configs = {}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def fetch_openid_config(issuer):
|
|
8
|
+
global _configs
|
|
9
|
+
|
|
10
|
+
if issuer not in _configs:
|
|
11
|
+
resp = requests.get(issuer.rstrip("/") + "/.well-known/openid-configuration")
|
|
12
|
+
_configs[issuer] = resp.json()
|
|
13
|
+
|
|
14
|
+
return _configs[issuer]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import urllib.parse
|
|
3
|
+
|
|
4
|
+
import colander
|
|
5
|
+
import requests
|
|
6
|
+
from pyramid import httpexceptions
|
|
7
|
+
|
|
8
|
+
from kinto.core import Service
|
|
9
|
+
from kinto.core.cornice.validators import colander_validator
|
|
10
|
+
from kinto.core.errors import ERRORS, raise_invalid
|
|
11
|
+
from kinto.core.resource.schema import ErrorResponseSchema
|
|
12
|
+
from kinto.core.schema import URL
|
|
13
|
+
from kinto.core.utils import random_bytes_hex
|
|
14
|
+
|
|
15
|
+
from .utils import fetch_openid_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_STATE_TTL_SECONDS = 3600
|
|
19
|
+
DEFAULT_STATE_LENGTH = 32
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RedirectHeadersSchema(colander.MappingSchema):
|
|
23
|
+
"""Redirect response headers."""
|
|
24
|
+
|
|
25
|
+
location = colander.SchemaNode(colander.String(), name="Location")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RedirectResponseSchema(colander.MappingSchema):
|
|
29
|
+
"""Redirect response schema."""
|
|
30
|
+
|
|
31
|
+
headers = RedirectHeadersSchema()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
response_schemas = {
|
|
35
|
+
"307": RedirectResponseSchema(description="Successful redirection."),
|
|
36
|
+
"400": ErrorResponseSchema(description="The request is invalid."),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def provider_validator(request, **kwargs):
|
|
41
|
+
"""
|
|
42
|
+
This validator verifies that the validator in URL (eg. /openid/auth0/login)
|
|
43
|
+
is a configured OpenIDConnect policy.
|
|
44
|
+
"""
|
|
45
|
+
provider = request.matchdict["provider"]
|
|
46
|
+
used = request.registry.settings.get("multiauth.policy.%s.use" % provider, "")
|
|
47
|
+
if not used.endswith("OpenIDConnectPolicy"):
|
|
48
|
+
request.errors.add("path", "provider", "Unknow provider %r" % provider)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LoginQuerystringSchema(colander.MappingSchema):
|
|
52
|
+
"""
|
|
53
|
+
Querystring schema for the login endpoint.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
callback = URL()
|
|
57
|
+
scope = colander.SchemaNode(colander.String())
|
|
58
|
+
prompt = colander.SchemaNode(
|
|
59
|
+
colander.String(), validator=colander.Regex("none"), missing=colander.drop
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LoginSchema(colander.MappingSchema):
|
|
64
|
+
querystring = LoginQuerystringSchema()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
login = Service(
|
|
68
|
+
name="openid_login", path="/openid/{provider}/login", description="Initiate the OAuth2 login"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@login.get(
|
|
73
|
+
schema=LoginSchema(),
|
|
74
|
+
validators=(colander_validator, provider_validator),
|
|
75
|
+
response_schemas=response_schemas,
|
|
76
|
+
)
|
|
77
|
+
def get_login(request):
|
|
78
|
+
"""Initiates to login dance for the specified scopes and callback URI
|
|
79
|
+
using appropriate redirections."""
|
|
80
|
+
|
|
81
|
+
# Settings.
|
|
82
|
+
provider = request.matchdict["provider"]
|
|
83
|
+
settings_prefix = "multiauth.policy.%s." % provider
|
|
84
|
+
issuer = request.registry.settings[settings_prefix + "issuer"]
|
|
85
|
+
client_id = request.registry.settings[settings_prefix + "client_id"]
|
|
86
|
+
userid_field = request.registry.settings.get(settings_prefix + "userid_field")
|
|
87
|
+
state_ttl = int(
|
|
88
|
+
request.registry.settings.get(
|
|
89
|
+
settings_prefix + "state_ttl_seconds", DEFAULT_STATE_TTL_SECONDS
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
state_length = int(
|
|
93
|
+
request.registry.settings.get(settings_prefix + "state_length", DEFAULT_STATE_LENGTH)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Read OpenID configuration (cached by issuer)
|
|
97
|
+
oid_config = fetch_openid_config(issuer)
|
|
98
|
+
auth_endpoint = oid_config["authorization_endpoint"]
|
|
99
|
+
|
|
100
|
+
scope = request.GET["scope"]
|
|
101
|
+
callback = request.GET["callback"]
|
|
102
|
+
prompt = request.GET.get("prompt")
|
|
103
|
+
|
|
104
|
+
# Check that email scope is requested if userid field is configured as email.
|
|
105
|
+
if userid_field == "email" and "email" not in scope:
|
|
106
|
+
error_details = {
|
|
107
|
+
"name": "scope",
|
|
108
|
+
"description": "Provider %s requires 'email' scope" % provider,
|
|
109
|
+
}
|
|
110
|
+
raise_invalid(request, **error_details)
|
|
111
|
+
|
|
112
|
+
# Generate a random string as state.
|
|
113
|
+
# And save it until code is traded.
|
|
114
|
+
state = random_bytes_hex(state_length)
|
|
115
|
+
request.registry.cache.set("openid:state:" + state, callback, ttl=state_ttl)
|
|
116
|
+
|
|
117
|
+
# Redirect the client to the Identity Provider that will eventually redirect
|
|
118
|
+
# to the OpenID token endpoint.
|
|
119
|
+
token_uri = request.route_url("openid_token", provider=provider)
|
|
120
|
+
params = dict(
|
|
121
|
+
client_id=client_id, response_type="code", scope=scope, redirect_uri=token_uri, state=state
|
|
122
|
+
)
|
|
123
|
+
if prompt:
|
|
124
|
+
# The 'prompt' parameter is optional.
|
|
125
|
+
params["prompt"] = prompt
|
|
126
|
+
redirect = f"{auth_endpoint}?{urllib.parse.urlencode(params)}"
|
|
127
|
+
raise httpexceptions.HTTPTemporaryRedirect(redirect)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TokenQuerystringSchema(colander.MappingSchema):
|
|
131
|
+
"""
|
|
132
|
+
Querystring schema for the token endpoint.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
code = colander.SchemaNode(colander.String())
|
|
136
|
+
state = colander.SchemaNode(colander.String())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TokenSchema(colander.MappingSchema):
|
|
140
|
+
querystring = TokenQuerystringSchema()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
token = Service(name="openid_token", path="/openid/{provider}/token", description="")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@token.get(schema=TokenSchema(), validators=(colander_validator, provider_validator))
|
|
147
|
+
def get_token(request):
|
|
148
|
+
"""Trades the specified code and state against access and ID tokens.
|
|
149
|
+
The client is redirected to the original ``callback`` URI with the
|
|
150
|
+
result in querystring."""
|
|
151
|
+
|
|
152
|
+
# Settings.
|
|
153
|
+
provider = request.matchdict["provider"]
|
|
154
|
+
settings_prefix = "multiauth.policy.%s." % provider
|
|
155
|
+
issuer = request.registry.settings[settings_prefix + "issuer"]
|
|
156
|
+
client_id = request.registry.settings[settings_prefix + "client_id"]
|
|
157
|
+
client_secret = request.registry.settings[settings_prefix + "client_secret"]
|
|
158
|
+
|
|
159
|
+
# Read OpenID configuration (cached by issuer)
|
|
160
|
+
oid_config = fetch_openid_config(issuer)
|
|
161
|
+
token_endpoint = oid_config["token_endpoint"]
|
|
162
|
+
|
|
163
|
+
code = request.GET["code"]
|
|
164
|
+
state = request.GET["state"]
|
|
165
|
+
|
|
166
|
+
# State can be used only once.
|
|
167
|
+
callback = request.registry.cache.delete("openid:state:" + state)
|
|
168
|
+
if callback is None:
|
|
169
|
+
error_details = {
|
|
170
|
+
"name": "state",
|
|
171
|
+
"description": "Invalid state",
|
|
172
|
+
"errno": ERRORS.INVALID_AUTH_TOKEN.value,
|
|
173
|
+
}
|
|
174
|
+
raise_invalid(request, **error_details)
|
|
175
|
+
|
|
176
|
+
# Trade the code for tokens on the Identity Provider.
|
|
177
|
+
# Google Identity requires to specify again redirect_uri.
|
|
178
|
+
redirect_uri = request.route_url("openid_token", provider=provider)
|
|
179
|
+
data = {
|
|
180
|
+
"code": code,
|
|
181
|
+
"client_id": client_id,
|
|
182
|
+
"client_secret": client_secret,
|
|
183
|
+
"redirect_uri": redirect_uri,
|
|
184
|
+
"grant_type": "authorization_code",
|
|
185
|
+
}
|
|
186
|
+
resp = requests.post(token_endpoint, data=data)
|
|
187
|
+
|
|
188
|
+
# The IdP response is forwarded to the client in the querystring/location hash.
|
|
189
|
+
# (eg. callback=`http://localhost:3000/#tokens=`)
|
|
190
|
+
token_info = resp.text.encode("utf-8")
|
|
191
|
+
encoded_token = base64.b64encode(token_info)
|
|
192
|
+
redirect = callback + urllib.parse.quote(encoded_token.decode("utf-8"))
|
|
193
|
+
raise httpexceptions.HTTPTemporaryRedirect(redirect)
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import warnings
|
|
5
|
+
from time import perf_counter as time_now
|
|
6
|
+
|
|
7
|
+
from pyramid.exceptions import ConfigurationError
|
|
8
|
+
from pyramid.response import Response
|
|
9
|
+
from pyramid.settings import asbool, aslist
|
|
10
|
+
from zope.interface import implementer
|
|
11
|
+
|
|
12
|
+
from kinto.core import metrics
|
|
13
|
+
from kinto.core.utils import safe_wraps
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import prometheus_client as prometheus_module
|
|
18
|
+
except ImportError: # pragma: no cover
|
|
19
|
+
prometheus_module = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_METRICS = {}
|
|
25
|
+
_REGISTRY = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PROMETHEUS_MULTIPROC_DIR = os.getenv("PROMETHEUS_MULTIPROC_DIR")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_registry():
|
|
32
|
+
global _REGISTRY
|
|
33
|
+
|
|
34
|
+
if _REGISTRY is None:
|
|
35
|
+
if PROMETHEUS_MULTIPROC_DIR: # pragma: no cover
|
|
36
|
+
from prometheus_client import multiprocess
|
|
37
|
+
|
|
38
|
+
_reset_multiproc_folder_content()
|
|
39
|
+
# Ref: https://prometheus.github.io/client_python/multiprocess/
|
|
40
|
+
_REGISTRY = prometheus_module.CollectorRegistry()
|
|
41
|
+
multiprocess.MultiProcessCollector(_REGISTRY)
|
|
42
|
+
else:
|
|
43
|
+
_REGISTRY = prometheus_module.REGISTRY
|
|
44
|
+
logger.warning("Prometheus metrics will run in single-process mode only.")
|
|
45
|
+
return _REGISTRY
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _fix_metric_name(s):
|
|
49
|
+
return s.replace("-", "_").replace(".", "_").replace(" ", "_")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Timer:
|
|
53
|
+
"""
|
|
54
|
+
A decorator to time the execution of a function. It will use the
|
|
55
|
+
`prometheus_client.Histogram` to record the time taken by the function
|
|
56
|
+
in seconds. The histogram is passed as an argument to the
|
|
57
|
+
constructor.
|
|
58
|
+
|
|
59
|
+
Main limitation: it does not support `labels` on the decorator.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, histogram):
|
|
63
|
+
self.histogram = histogram
|
|
64
|
+
self._start_time = None
|
|
65
|
+
|
|
66
|
+
def set_labels(self, labels):
|
|
67
|
+
if not labels:
|
|
68
|
+
return
|
|
69
|
+
self.histogram = self.histogram.labels(*(label_value for _, label_value in labels))
|
|
70
|
+
|
|
71
|
+
def observe(self, value):
|
|
72
|
+
return self.histogram.observe(value)
|
|
73
|
+
|
|
74
|
+
def __call__(self, f):
|
|
75
|
+
@safe_wraps(f)
|
|
76
|
+
def _wrapped(*args, **kwargs):
|
|
77
|
+
start_time = time_now()
|
|
78
|
+
try:
|
|
79
|
+
return f(*args, **kwargs)
|
|
80
|
+
finally:
|
|
81
|
+
dt_sec = time_now() - start_time
|
|
82
|
+
self.histogram.observe(dt_sec)
|
|
83
|
+
|
|
84
|
+
return _wrapped
|
|
85
|
+
|
|
86
|
+
def __enter__(self):
|
|
87
|
+
return self.start()
|
|
88
|
+
|
|
89
|
+
def __exit__(self, typ, value, tb):
|
|
90
|
+
self.stop()
|
|
91
|
+
|
|
92
|
+
def start(self):
|
|
93
|
+
self._start_time = time_now()
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def stop(self):
|
|
97
|
+
if self._start_time is None: # pragma: nocover
|
|
98
|
+
raise RuntimeError("Timer has not started.")
|
|
99
|
+
dt_sec = time_now() - self._start_time
|
|
100
|
+
self.histogram.observe(dt_sec)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class NoOpHistogram: # pragma: no cover
|
|
105
|
+
def observe(self, value):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def labels(self, *args):
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@implementer(metrics.IMetricsService)
|
|
113
|
+
class PrometheusService:
|
|
114
|
+
def __init__(self, prefix="", disabled_metrics=[], histogram_buckets=None):
|
|
115
|
+
prefix_clean = ""
|
|
116
|
+
if prefix:
|
|
117
|
+
# In GCP Console, the metrics are grouped by the first
|
|
118
|
+
# word before the first underscore. Here we make sure the specified
|
|
119
|
+
# prefix is not mixed up with metrics names.
|
|
120
|
+
# (eg. `remote-settings` -> `remotesettings_`, `kinto_` -> `kinto_`)
|
|
121
|
+
prefix_clean = _fix_metric_name(prefix).replace("_", "") + "_"
|
|
122
|
+
self.prefix = prefix_clean.lower()
|
|
123
|
+
self.disabled_metrics = [m.replace(self.prefix, "") for m in disabled_metrics]
|
|
124
|
+
self.histogram_buckets = histogram_buckets
|
|
125
|
+
|
|
126
|
+
def timer(self, key, value=None, labels=[]):
|
|
127
|
+
global _METRICS
|
|
128
|
+
|
|
129
|
+
key = _fix_metric_name(key)
|
|
130
|
+
if key in self.disabled_metrics:
|
|
131
|
+
return Timer(histogram=NoOpHistogram())
|
|
132
|
+
|
|
133
|
+
key = self.prefix + key
|
|
134
|
+
if key not in _METRICS:
|
|
135
|
+
_METRICS[key] = prometheus_module.Histogram(
|
|
136
|
+
key,
|
|
137
|
+
f"Histogram of {key}",
|
|
138
|
+
labelnames=[label_name for label_name, _ in labels],
|
|
139
|
+
buckets=self.histogram_buckets,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if not isinstance(_METRICS[key], prometheus_module.Histogram):
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
f"Metric {key} already exists with different type ({_METRICS[key]})"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
timer = Timer(histogram=_METRICS[key])
|
|
148
|
+
timer.set_labels(labels)
|
|
149
|
+
|
|
150
|
+
if value is not None:
|
|
151
|
+
# We are timing something.
|
|
152
|
+
return timer.observe(value)
|
|
153
|
+
|
|
154
|
+
# We are not timing anything, just returning the timer object
|
|
155
|
+
# (eg. to be used as decorator or context manager).
|
|
156
|
+
# Note that in this case, the labels values will be the same for all calls.
|
|
157
|
+
return timer
|
|
158
|
+
|
|
159
|
+
def observe(self, key, value, labels=[]):
|
|
160
|
+
global _METRICS
|
|
161
|
+
|
|
162
|
+
key = _fix_metric_name(key)
|
|
163
|
+
if key in self.disabled_metrics:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
key = self.prefix + key
|
|
167
|
+
if key not in _METRICS:
|
|
168
|
+
_METRICS[key] = prometheus_module.Summary(
|
|
169
|
+
key,
|
|
170
|
+
f"Summary of {key}",
|
|
171
|
+
labelnames=[label_name for label_name, _ in labels],
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if not isinstance(_METRICS[key], prometheus_module.Summary):
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
f"Metric {key} already exists with different type ({_METRICS[key]})"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
m = _METRICS[key]
|
|
180
|
+
if labels:
|
|
181
|
+
m = m.labels(*(label_value for _, label_value in labels))
|
|
182
|
+
|
|
183
|
+
m.observe(value)
|
|
184
|
+
|
|
185
|
+
def count(self, key, count=1, unique=None):
|
|
186
|
+
global _METRICS
|
|
187
|
+
|
|
188
|
+
key = _fix_metric_name(key)
|
|
189
|
+
if key in self.disabled_metrics:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
labels = []
|
|
193
|
+
if unique:
|
|
194
|
+
if isinstance(unique, str):
|
|
195
|
+
warnings.warn(
|
|
196
|
+
"`unique` parameter should be of type ``list[tuple[str, str]]``",
|
|
197
|
+
DeprecationWarning,
|
|
198
|
+
)
|
|
199
|
+
# Turn `unique` into a group and a value:
|
|
200
|
+
# "bob" -> "group.bob"
|
|
201
|
+
# "method.basicauth.mat" -> [("method_basicauth", "mat")]`
|
|
202
|
+
if "." not in unique:
|
|
203
|
+
unique = f"group.{unique}"
|
|
204
|
+
label_name, label_value = unique.rsplit(".", 1)
|
|
205
|
+
unique = [(label_name, label_value)]
|
|
206
|
+
|
|
207
|
+
labels = [
|
|
208
|
+
(_fix_metric_name(label_name), label_value) for label_name, label_value in unique
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
key = self.prefix + key
|
|
212
|
+
if key not in _METRICS:
|
|
213
|
+
_METRICS[key] = prometheus_module.Counter(
|
|
214
|
+
key,
|
|
215
|
+
f"Counter of {key}",
|
|
216
|
+
labelnames=[label_name for label_name, _ in labels],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if not isinstance(_METRICS[key], prometheus_module.Counter):
|
|
220
|
+
raise RuntimeError(
|
|
221
|
+
f"Metric {key} already exists with different type ({_METRICS[key]})"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
m = _METRICS[key]
|
|
225
|
+
if labels:
|
|
226
|
+
m = m.labels(*(label_value for _, label_value in labels))
|
|
227
|
+
|
|
228
|
+
m.inc(count)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def metrics_view(request):
|
|
232
|
+
registry = get_registry()
|
|
233
|
+
data = prometheus_module.generate_latest(registry)
|
|
234
|
+
resp = Response(body=data)
|
|
235
|
+
resp.headers["Content-Type"] = prometheus_module.CONTENT_TYPE_LATEST
|
|
236
|
+
resp.headers["Content-Length"] = str(len(data))
|
|
237
|
+
return resp
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _reset_multiproc_folder_content(): # pragma: no cover
|
|
241
|
+
shutil.rmtree(PROMETHEUS_MULTIPROC_DIR, ignore_errors=True)
|
|
242
|
+
os.makedirs(PROMETHEUS_MULTIPROC_DIR, exist_ok=True)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def reset_registry():
|
|
246
|
+
# This is mainly useful in tests, where the plugin is included
|
|
247
|
+
# several times with different settings.
|
|
248
|
+
registry = get_registry()
|
|
249
|
+
|
|
250
|
+
for collector in _METRICS.values():
|
|
251
|
+
try:
|
|
252
|
+
registry.unregister(collector)
|
|
253
|
+
except KeyError: # pragma: no cover
|
|
254
|
+
pass
|
|
255
|
+
_METRICS.clear()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def includeme(config):
|
|
259
|
+
if prometheus_module is None:
|
|
260
|
+
error_msg = (
|
|
261
|
+
"Please install Kinto with monitoring dependencies (e.g. prometheus-client package)"
|
|
262
|
+
)
|
|
263
|
+
raise ConfigurationError(error_msg)
|
|
264
|
+
|
|
265
|
+
settings = config.get_settings()
|
|
266
|
+
|
|
267
|
+
if not asbool(settings.get("prometheus_created_metrics_enabled", True)):
|
|
268
|
+
prometheus_module.disable_created_metrics()
|
|
269
|
+
|
|
270
|
+
prefix = settings.get("prometheus_prefix", settings["project_name"])
|
|
271
|
+
disabled_metrics = aslist(settings.get("prometheus_disabled_metrics", ""))
|
|
272
|
+
|
|
273
|
+
# Default buckets for histogram metrics are (.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, INF)
|
|
274
|
+
# we reduce it from 15 to 8 values by default here, and let the user override it if needed.
|
|
275
|
+
histogram_buckets_values = aslist(
|
|
276
|
+
settings.get(
|
|
277
|
+
"prometheus_histogram_buckets", "0.01 0.05 0.1 0.5 1.0 3.0 6.0 Inf"
|
|
278
|
+
) # Note: Inf is added by default.
|
|
279
|
+
)
|
|
280
|
+
histogram_buckets = [float(x) for x in histogram_buckets_values]
|
|
281
|
+
# Note: we don't need to check for INF or list size, it's done in the prometheus_client library.
|
|
282
|
+
|
|
283
|
+
get_registry() # Initialize the registry.
|
|
284
|
+
|
|
285
|
+
metrics_impl = PrometheusService(
|
|
286
|
+
prefix=prefix, disabled_metrics=disabled_metrics, histogram_buckets=histogram_buckets
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
config.add_api_capability(
|
|
290
|
+
"prometheus",
|
|
291
|
+
description="Prometheus metrics.",
|
|
292
|
+
url="https://github.com/Kinto/kinto/",
|
|
293
|
+
prefix=metrics_impl.prefix,
|
|
294
|
+
disabled_metrics=disabled_metrics,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
config.add_route("prometheus_metrics", "/__metrics__")
|
|
298
|
+
config.add_view(metrics_view, route_name="prometheus_metrics")
|
|
299
|
+
|
|
300
|
+
config.registry.registerUtility(metrics_impl, metrics.IMetricsService)
|