commonground-api-common 1.12.2__py3-none-any.whl → 2.4.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.
- commonground_api_common-1.12.2.data/scripts/patch_content_types → commonground_api_common-2.4.1.data/scripts/generate_schema +2 -4
- {commonground_api_common-1.12.2.dist-info → commonground_api_common-2.4.1.dist-info}/METADATA +47 -40
- {commonground_api_common-1.12.2.dist-info → commonground_api_common-2.4.1.dist-info}/RECORD +47 -52
- {commonground_api_common-1.12.2.dist-info → commonground_api_common-2.4.1.dist-info}/WHEEL +1 -1
- vng_api_common/__init__.py +1 -1
- vng_api_common/admin.py +1 -20
- vng_api_common/api/views.py +1 -0
- vng_api_common/apps.py +44 -26
- vng_api_common/audittrails/utils.py +44 -0
- vng_api_common/authorizations/admin.py +1 -1
- vng_api_common/authorizations/middleware.py +244 -0
- vng_api_common/authorizations/migrations/0016_remove_authorizationsconfig_api_root_and_more.py +76 -0
- vng_api_common/authorizations/models.py +62 -3
- vng_api_common/authorizations/utils.py +17 -0
- vng_api_common/authorizations/validators.py +5 -11
- vng_api_common/caching/etags.py +2 -1
- vng_api_common/client.py +61 -29
- vng_api_common/conf/api.py +33 -48
- vng_api_common/contrib/setup_configuration/models.py +32 -0
- vng_api_common/contrib/setup_configuration/steps.py +46 -0
- vng_api_common/extensions/file.py +26 -0
- vng_api_common/extensions/gegevensgroep.py +16 -0
- vng_api_common/extensions/geojson.py +270 -0
- vng_api_common/extensions/hyperlink.py +37 -0
- vng_api_common/extensions/polymorphic.py +68 -0
- vng_api_common/extensions/query.py +20 -0
- vng_api_common/filters.py +0 -1
- vng_api_common/generators.py +12 -113
- vng_api_common/middleware.py +1 -227
- vng_api_common/migrations/0006_delete_apicredential.py +120 -0
- vng_api_common/mocks.py +4 -1
- vng_api_common/models.py +10 -111
- vng_api_common/notifications/api/views.py +8 -8
- vng_api_common/notifications/handlers.py +8 -3
- vng_api_common/notifications/migrations/0011_remove_subscription_config_and_more.py +23 -0
- vng_api_common/oas.py +6 -10
- vng_api_common/pagination.py +10 -0
- vng_api_common/routers.py +3 -3
- vng_api_common/schema.py +414 -158
- vng_api_common/tests/schema.py +13 -0
- vng_api_common/utils.py +0 -22
- vng_api_common/validators.py +111 -113
- vng_api_common/views.py +35 -20
- commonground_api_common-1.12.2.data/scripts/generate_schema +0 -39
- commonground_api_common-1.12.2.data/scripts/use_external_components +0 -16
- vng_api_common/inspectors/cache.py +0 -57
- vng_api_common/inspectors/fields.py +0 -126
- vng_api_common/inspectors/files.py +0 -121
- vng_api_common/inspectors/geojson.py +0 -360
- vng_api_common/inspectors/polymorphic.py +0 -72
- vng_api_common/inspectors/query.py +0 -96
- vng_api_common/inspectors/utils.py +0 -40
- vng_api_common/inspectors/view.py +0 -547
- vng_api_common/management/commands/generate_autorisaties.py +0 -43
- vng_api_common/management/commands/generate_notificaties.py +0 -40
- vng_api_common/management/commands/generate_swagger.py +0 -197
- vng_api_common/management/commands/patch_error_contenttypes.py +0 -61
- vng_api_common/management/commands/use_external_components.py +0 -94
- vng_api_common/notifications/constants.py +0 -3
- vng_api_common/notifications/models.py +0 -97
- vng_api_common/templates/vng_api_common/api_schema_to_markdown_table.md +0 -16
- vng_api_common/templates/vng_api_common/autorisaties.md +0 -15
- vng_api_common/templates/vng_api_common/notificaties.md +0 -24
- {commonground_api_common-1.12.2.dist-info → commonground_api_common-2.4.1.dist-info}/top_level.txt +0 -0
- /vng_api_common/{inspectors → contrib}/__init__.py +0 -0
- /vng_api_common/{management → contrib/setup_configuration}/__init__.py +0 -0
- /vng_api_common/{management/commands → extensions}/__init__.py +0 -0
vng_api_common/generators.py
CHANGED
@@ -1,16 +1,7 @@
|
|
1
|
-
from
|
2
|
-
from typing import List
|
3
|
-
|
4
|
-
from drf_yasg import openapi
|
5
|
-
from drf_yasg.generators import (
|
1
|
+
from drf_spectacular.generators import (
|
6
2
|
EndpointEnumerator as _EndpointEnumerator,
|
7
|
-
|
3
|
+
SchemaGenerator as _OpenAPISchemaGenerator,
|
8
4
|
)
|
9
|
-
from drf_yasg.utils import get_consumes, get_produces
|
10
|
-
from rest_framework.schemas.utils import is_list_view
|
11
|
-
from rest_framework.settings import api_settings
|
12
|
-
|
13
|
-
from vng_api_common.utils import get_view_summary
|
14
5
|
|
15
6
|
|
16
7
|
class EndpointEnumerator(_EndpointEnumerator):
|
@@ -29,110 +20,18 @@ class EndpointEnumerator(_EndpointEnumerator):
|
|
29
20
|
|
30
21
|
|
31
22
|
class OpenAPISchemaGenerator(_OpenAPISchemaGenerator):
|
32
|
-
|
33
|
-
|
34
|
-
def get_tags(self, request=None, public=False):
|
35
|
-
"""Retrieve the tags for the root schema.
|
36
|
-
|
37
|
-
:param request: the request used for filtering accessible endpoints and finding the spec URI
|
38
|
-
:param bool public: if True, all endpoints are included regardless of access through `request`
|
39
|
-
|
40
|
-
:return: List of tags containing the tag name and a description.
|
41
|
-
"""
|
42
|
-
tags = {}
|
23
|
+
endpoint_inspector_cls = EndpointEnumerator
|
43
24
|
|
44
|
-
|
45
|
-
for path, (view_cls, methods) in sorted(endpoints.items()):
|
46
|
-
if "{" in path:
|
47
|
-
continue
|
48
|
-
|
49
|
-
tag = path.rsplit("/", 1)[-1]
|
50
|
-
if tag in tags:
|
51
|
-
continue
|
52
|
-
|
53
|
-
# exclude special non-rest actions
|
54
|
-
if tag.startswith("_"):
|
55
|
-
continue
|
56
|
-
tags[tag] = get_view_summary(view_cls)
|
57
|
-
|
58
|
-
return [
|
59
|
-
OrderedDict([("name", operation), ("description", desc)])
|
60
|
-
for operation, desc in sorted(tags.items())
|
61
|
-
]
|
62
|
-
|
63
|
-
def get_schema(self, request=None, public=False):
|
64
|
-
"""
|
65
|
-
Rewrite parent class to add 'responses' in components
|
25
|
+
def create_view(self, callback, method, request=None):
|
66
26
|
"""
|
67
|
-
|
68
|
-
components = self.reference_resolver_class(
|
69
|
-
openapi.SCHEMA_DEFINITIONS, "responses", force_init=True
|
70
|
-
)
|
71
|
-
self.consumes = get_consumes(api_settings.DEFAULT_PARSER_CLASSES)
|
72
|
-
self.produces = get_produces(api_settings.DEFAULT_RENDERER_CLASSES)
|
73
|
-
paths, prefix = self.get_paths(endpoints, components, request, public)
|
74
|
-
|
75
|
-
security_definitions = self.get_security_definitions()
|
76
|
-
if security_definitions:
|
77
|
-
security_requirements = self.get_security_requirements(security_definitions)
|
78
|
-
else:
|
79
|
-
security_requirements = None
|
80
|
-
|
81
|
-
url = self.url
|
82
|
-
if url is None and request is not None:
|
83
|
-
url = request.build_absolute_uri()
|
84
|
-
|
85
|
-
return openapi.Swagger(
|
86
|
-
info=self.info,
|
87
|
-
paths=paths,
|
88
|
-
consumes=self.consumes or None,
|
89
|
-
produces=self.produces or None,
|
90
|
-
tags=self.get_tags(request, public),
|
91
|
-
security_definitions=security_definitions,
|
92
|
-
security=security_requirements,
|
93
|
-
_url=url,
|
94
|
-
_prefix=prefix,
|
95
|
-
_version=self.version,
|
96
|
-
**dict(components),
|
97
|
-
)
|
98
|
-
|
99
|
-
def get_path_parameters(self, path, view_cls):
|
100
|
-
"""Return a list of Parameter instances corresponding to any templated path variables.
|
101
|
-
|
102
|
-
:param str path: templated request path
|
103
|
-
:param type view_cls: the view class associated with the path
|
104
|
-
:return: path parameters
|
105
|
-
:rtype: list[openapi.Parameter]
|
27
|
+
workaround for HEAD method which doesn't have action
|
106
28
|
"""
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
if not parameter.name.endswith("_uuid"):
|
113
|
-
continue
|
114
|
-
parameter.format = openapi.FORMAT_UUID
|
115
|
-
parameter.description = "Unieke resource identifier (UUID4)"
|
116
|
-
return parameters
|
117
|
-
|
118
|
-
def get_operation_keys(self, subpath, method, view) -> List[str]:
|
119
|
-
if method != "HEAD":
|
120
|
-
return super().get_operation_keys(subpath, method, view)
|
121
|
-
|
122
|
-
assert not is_list_view(
|
123
|
-
subpath, method, view
|
124
|
-
), "HEAD requests are only supported on detail endpoints"
|
125
|
-
|
126
|
-
# taken from DRF schema generation
|
127
|
-
named_path_components = [
|
128
|
-
component
|
129
|
-
for component in subpath.strip("/").split("/")
|
130
|
-
if "{" not in component
|
131
|
-
]
|
29
|
+
if method == "HEAD":
|
30
|
+
view = super(_OpenAPISchemaGenerator, self).create_view(
|
31
|
+
callback, method, request=request
|
32
|
+
)
|
33
|
+
return view
|
132
34
|
|
133
|
-
return
|
35
|
+
return super().create_view(callback, method, request=request)
|
134
36
|
|
135
|
-
|
136
|
-
if method == "HEAD":
|
137
|
-
return {}
|
138
|
-
return super().get_overrides(view, method)
|
37
|
+
# todo support registering and reusing Response components
|
vng_api_common/middleware.py
CHANGED
@@ -1,242 +1,16 @@
|
|
1
1
|
# https://pyjwt.readthedocs.io/en/latest/usage.html#reading-headers-without-validation
|
2
2
|
# -> we can put the organization/service in the headers itself
|
3
3
|
import logging
|
4
|
-
from typing import Any, Dict, Iterable, List, Optional
|
5
4
|
|
6
5
|
from django.conf import settings
|
7
|
-
from django.db import models, transaction
|
8
|
-
from django.db.models import QuerySet
|
9
|
-
from django.utils.translation import gettext as _
|
10
6
|
|
11
|
-
import jwt
|
12
|
-
from djangorestframework_camel_case.util import underscoreize
|
13
|
-
from rest_framework.exceptions import PermissionDenied
|
14
7
|
from rest_framework.response import Response
|
15
|
-
from zds_client.client import ClientError
|
16
8
|
|
17
|
-
from .
|
18
|
-
from .authorizations.serializers import ApplicatieUuidSerializer
|
19
|
-
from .constants import VERSION_HEADER, VertrouwelijkheidsAanduiding
|
20
|
-
from .models import JWTSecret
|
21
|
-
from .utils import get_uuid_from_path
|
9
|
+
from .constants import VERSION_HEADER
|
22
10
|
|
23
11
|
logger = logging.getLogger(__name__)
|
24
12
|
|
25
13
|
|
26
|
-
class JWTAuth:
|
27
|
-
def __init__(self, encoded: str = None):
|
28
|
-
self.encoded = encoded
|
29
|
-
|
30
|
-
@property
|
31
|
-
def applicaties(self) -> Iterable[Applicatie]:
|
32
|
-
if self.client_id is None:
|
33
|
-
return []
|
34
|
-
|
35
|
-
applicaties = self._get_auth()
|
36
|
-
|
37
|
-
if not applicaties:
|
38
|
-
auth_data = self._request_auth()
|
39
|
-
applicaties = self._save_auth(auth_data)
|
40
|
-
|
41
|
-
return applicaties
|
42
|
-
|
43
|
-
@property
|
44
|
-
def autorisaties(self) -> models.QuerySet:
|
45
|
-
"""
|
46
|
-
Retrieve all authorizations relevant to this component.
|
47
|
-
"""
|
48
|
-
app_ids = [app.id for app in self.applicaties]
|
49
|
-
config = AuthorizationsConfig.get_solo()
|
50
|
-
return Autorisatie.objects.filter(
|
51
|
-
applicatie_id__in=app_ids, component=config.component
|
52
|
-
)
|
53
|
-
|
54
|
-
def _request_auth(self) -> list:
|
55
|
-
client = AuthorizationsConfig.get_client()
|
56
|
-
try:
|
57
|
-
response = client.list(
|
58
|
-
"applicatie", query_params={"clientIds": self.client_id}
|
59
|
-
)
|
60
|
-
except ClientError as exc:
|
61
|
-
response = exc.args[0]
|
62
|
-
# friendly debug - hint at where the problem is located
|
63
|
-
if response["status"] == 403 and response["code"] == "not_authenticated":
|
64
|
-
detail = _(
|
65
|
-
"Component could not authenticate against the AC - "
|
66
|
-
"authorizations could not be retrieved"
|
67
|
-
)
|
68
|
-
raise PermissionDenied(detail=detail, code="not_authenticated_for_ac")
|
69
|
-
logger.warn("Authorization component can't be accessed")
|
70
|
-
return []
|
71
|
-
|
72
|
-
return underscoreize(response["results"])
|
73
|
-
|
74
|
-
def _get_auth(self):
|
75
|
-
return Applicatie.objects.filter(client_ids__contains=[self.client_id])
|
76
|
-
|
77
|
-
@transaction.atomic
|
78
|
-
def _save_auth(self, auth_data):
|
79
|
-
applicaties = []
|
80
|
-
|
81
|
-
for applicatie_data in auth_data:
|
82
|
-
applicatie_serializer = ApplicatieUuidSerializer(data=applicatie_data)
|
83
|
-
uuid = get_uuid_from_path(applicatie_data["url"])
|
84
|
-
applicatie_data["uuid"] = uuid
|
85
|
-
applicatie_serializer.is_valid()
|
86
|
-
applicaties.append(applicatie_serializer.save())
|
87
|
-
|
88
|
-
return applicaties
|
89
|
-
|
90
|
-
@property
|
91
|
-
def payload(self) -> Optional[Dict[str, Any]]:
|
92
|
-
if self.encoded is None:
|
93
|
-
return None
|
94
|
-
|
95
|
-
if not hasattr(self, "_payload"):
|
96
|
-
# decode the JWT and validate it
|
97
|
-
|
98
|
-
# jwt check
|
99
|
-
try:
|
100
|
-
payload = jwt.decode(
|
101
|
-
self.encoded,
|
102
|
-
algorithms=["HS256"],
|
103
|
-
options={"verify_signature": False},
|
104
|
-
leeway=settings.JWT_LEEWAY,
|
105
|
-
)
|
106
|
-
except jwt.DecodeError:
|
107
|
-
logger.info("Invalid JWT encountered")
|
108
|
-
raise PermissionDenied(
|
109
|
-
_(
|
110
|
-
"JWT could not be decoded. Possibly you made a copy-paste mistake."
|
111
|
-
),
|
112
|
-
code="jwt-decode-error",
|
113
|
-
)
|
114
|
-
|
115
|
-
# get client_id
|
116
|
-
try:
|
117
|
-
client_id = payload["client_id"]
|
118
|
-
except KeyError:
|
119
|
-
raise PermissionDenied(
|
120
|
-
"Client identifier is niet aanwezig in JWT",
|
121
|
-
code="missing-client-identifier",
|
122
|
-
)
|
123
|
-
|
124
|
-
# find client_id in DB and retrieve its secret
|
125
|
-
try:
|
126
|
-
jwt_secret = JWTSecret.objects.exclude(secret="").get(
|
127
|
-
identifier=client_id
|
128
|
-
)
|
129
|
-
except JWTSecret.DoesNotExist:
|
130
|
-
raise PermissionDenied(
|
131
|
-
"Client identifier bestaat niet", code="invalid-client-identifier"
|
132
|
-
)
|
133
|
-
else:
|
134
|
-
key = jwt_secret.secret
|
135
|
-
|
136
|
-
# check signature of the token
|
137
|
-
try:
|
138
|
-
payload = jwt.decode(
|
139
|
-
self.encoded,
|
140
|
-
key,
|
141
|
-
algorithms=["HS256"],
|
142
|
-
leeway=settings.JWT_LEEWAY,
|
143
|
-
)
|
144
|
-
except jwt.InvalidSignatureError:
|
145
|
-
logger.exception("Invalid signature - possible payload tampering?")
|
146
|
-
raise PermissionDenied(
|
147
|
-
"Client credentials zijn niet geldig", code="invalid-jwt-signature"
|
148
|
-
)
|
149
|
-
|
150
|
-
self._payload = payload
|
151
|
-
|
152
|
-
return self._payload
|
153
|
-
|
154
|
-
@property
|
155
|
-
def client_id(self) -> str:
|
156
|
-
if not self.payload:
|
157
|
-
return None
|
158
|
-
return self.payload["client_id"]
|
159
|
-
|
160
|
-
def filter_vertrouwelijkheidaanduiding(self, base: QuerySet, value) -> QuerySet:
|
161
|
-
if value is None:
|
162
|
-
return base
|
163
|
-
|
164
|
-
order_provided = VertrouwelijkheidsAanduiding.get_choice_order(value)
|
165
|
-
order_case = VertrouwelijkheidsAanduiding.get_order_expression(
|
166
|
-
"max_vertrouwelijkheidaanduiding"
|
167
|
-
)
|
168
|
-
|
169
|
-
# In this case we are filtering Autorisatie model to look for auth which meets our needs.
|
170
|
-
# Therefore we're only considering authorizations here that have a max_vertrouwelijkheidaanduiding
|
171
|
-
# bigger or equal than what we're checking for the object.
|
172
|
-
# In cases when we are filtering data objects (Zaak, InformatieObject etc) it's the other way around
|
173
|
-
|
174
|
-
return base.annotate(max_vertr=order_case).filter(max_vertr__gte=order_provided)
|
175
|
-
|
176
|
-
def filter_default(self, base: QuerySet, name, value) -> QuerySet:
|
177
|
-
if value is None:
|
178
|
-
return base
|
179
|
-
|
180
|
-
return base.filter(**{name: value})
|
181
|
-
|
182
|
-
def has_auth(
|
183
|
-
self, scopes: List[str], component: Optional[str] = None, **fields
|
184
|
-
) -> bool:
|
185
|
-
if scopes is None:
|
186
|
-
return False
|
187
|
-
|
188
|
-
scopes_provided = set()
|
189
|
-
config = AuthorizationsConfig.get_solo()
|
190
|
-
if component is None:
|
191
|
-
component = config.component
|
192
|
-
|
193
|
-
for applicatie in self.applicaties:
|
194
|
-
# allow everything
|
195
|
-
if applicatie.heeft_alle_autorisaties is True:
|
196
|
-
return True
|
197
|
-
|
198
|
-
autorisaties = applicatie.autorisaties.filter(component=component)
|
199
|
-
|
200
|
-
# filter on all additional components
|
201
|
-
for field_name, field_value in fields.items():
|
202
|
-
if hasattr(self, f"filter_{field_name}"):
|
203
|
-
autorisaties = getattr(self, f"filter_{field_name}")(
|
204
|
-
autorisaties, field_value
|
205
|
-
)
|
206
|
-
else:
|
207
|
-
autorisaties = self.filter_default(
|
208
|
-
autorisaties, field_name, field_value
|
209
|
-
)
|
210
|
-
|
211
|
-
for autorisatie in autorisaties:
|
212
|
-
scopes_provided.update(autorisatie.scopes)
|
213
|
-
|
214
|
-
return scopes.is_contained_in(list(scopes_provided))
|
215
|
-
|
216
|
-
|
217
|
-
class AuthMiddleware:
|
218
|
-
header = "HTTP_AUTHORIZATION"
|
219
|
-
auth_type = "Bearer"
|
220
|
-
|
221
|
-
def __init__(self, get_response=None):
|
222
|
-
self.get_response = get_response
|
223
|
-
|
224
|
-
def __call__(self, request):
|
225
|
-
self.extract_jwt_payload(request)
|
226
|
-
return self.get_response(request) if self.get_response else None
|
227
|
-
|
228
|
-
def extract_jwt_payload(self, request):
|
229
|
-
authorization = request.META.get(self.header, "")
|
230
|
-
prefix = f"{self.auth_type} "
|
231
|
-
if authorization.startswith(prefix):
|
232
|
-
# grab the actual token
|
233
|
-
encoded = authorization[len(prefix) :]
|
234
|
-
else:
|
235
|
-
encoded = None
|
236
|
-
|
237
|
-
request.jwt_auth = JWTAuth(encoded)
|
238
|
-
|
239
|
-
|
240
14
|
class APIVersionHeaderMiddleware:
|
241
15
|
"""
|
242
16
|
Include a header specifying the API-version
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# Generated by Django 5.1.2 on 2024-10-24 13:51
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from typing import Set
|
5
|
+
|
6
|
+
from django.db import migrations, models
|
7
|
+
from django.utils.text import slugify
|
8
|
+
|
9
|
+
from zgw_consumers.constants import APITypes, AuthTypes
|
10
|
+
|
11
|
+
logger = logging.getLogger(__name__)
|
12
|
+
|
13
|
+
|
14
|
+
def _get_api_type(api_root: str) -> APITypes:
|
15
|
+
mapping = {
|
16
|
+
"/autorisaties/api/": APITypes.ac,
|
17
|
+
"/zaken/api/": APITypes.zrc,
|
18
|
+
"/catalogi/api/": APITypes.ztc,
|
19
|
+
"/documenten/api/": APITypes.drc,
|
20
|
+
"/besluiten/api/": APITypes.drc,
|
21
|
+
}
|
22
|
+
|
23
|
+
for path, _type in mapping.items():
|
24
|
+
if path in api_root.lower():
|
25
|
+
return _type
|
26
|
+
|
27
|
+
return APITypes.orc
|
28
|
+
|
29
|
+
|
30
|
+
def _get_service_slug(credential: models.Model, existing_slugs: Set[str]) -> str:
|
31
|
+
default_slug: str = slugify(credential.label)
|
32
|
+
|
33
|
+
if default_slug not in existing_slugs or not existing_slugs:
|
34
|
+
return default_slug
|
35
|
+
|
36
|
+
count = 2
|
37
|
+
slug = f"{default_slug}-{count}"
|
38
|
+
|
39
|
+
while slug in existing_slugs:
|
40
|
+
count += 1
|
41
|
+
slug = f"{default_slug}-{count}"
|
42
|
+
|
43
|
+
return slug
|
44
|
+
|
45
|
+
|
46
|
+
def migrate_credentials_to_service(apps, _) -> None:
|
47
|
+
APICredential = apps.get_model("vng_api_common", "APICredential")
|
48
|
+
Service = apps.get_model("zgw_consumers", "Service")
|
49
|
+
|
50
|
+
credentials = APICredential.objects.all()
|
51
|
+
|
52
|
+
existings_service_slugs = set(Service.objects.values_list("slug", flat=True))
|
53
|
+
|
54
|
+
for credential in credentials:
|
55
|
+
logger.info(f"Creating Service for {credential.client_id}")
|
56
|
+
|
57
|
+
service_slug = _get_service_slug(credential, existings_service_slugs)
|
58
|
+
|
59
|
+
_, created = Service.objects.get_or_create(
|
60
|
+
api_root=credential.api_root,
|
61
|
+
defaults=dict(
|
62
|
+
label=credential.label,
|
63
|
+
slug=service_slug,
|
64
|
+
api_type=_get_api_type(credential.api_root),
|
65
|
+
auth_type=AuthTypes.zgw,
|
66
|
+
client_id=credential.client_id,
|
67
|
+
secret=credential.secret,
|
68
|
+
user_id=credential.user_id,
|
69
|
+
user_representation=credential.user_representation,
|
70
|
+
),
|
71
|
+
)
|
72
|
+
|
73
|
+
existings_service_slugs.add(service_slug)
|
74
|
+
|
75
|
+
if created:
|
76
|
+
logger.info(f"Created new Service for {credential.api_root}")
|
77
|
+
else:
|
78
|
+
logger.info(f"Existing service found for {credential.api_root}")
|
79
|
+
|
80
|
+
|
81
|
+
def migrate_service_to_credentials(apps, _) -> None:
|
82
|
+
APICredential = apps.get_model("vng_api_common", "APICredential")
|
83
|
+
Service = apps.get_model("zgw_consumers", "Service")
|
84
|
+
|
85
|
+
services = Service.objects.filter(auth_type=AuthTypes.zgw)
|
86
|
+
|
87
|
+
for service in services:
|
88
|
+
logger.info(f"Creating APICredentials for {service.client_id}")
|
89
|
+
|
90
|
+
_, created = APICredential.objects.get_or_create(
|
91
|
+
api_root=service.api_root,
|
92
|
+
defaults=dict(
|
93
|
+
label=f"Migrated credentials for {service.client_id}",
|
94
|
+
client_id=service.client_id,
|
95
|
+
secret=service.secret,
|
96
|
+
user_id=service.user_id,
|
97
|
+
user_representation=service.user_representation,
|
98
|
+
),
|
99
|
+
)
|
100
|
+
if created:
|
101
|
+
logger.info(f"Created new APICredentials for {service.api_root}")
|
102
|
+
else:
|
103
|
+
logger.info(f"Existing APICredentials found for {service.api_root}")
|
104
|
+
|
105
|
+
|
106
|
+
class Migration(migrations.Migration):
|
107
|
+
|
108
|
+
dependencies = [
|
109
|
+
("vng_api_common", "0005_auto_20190614_1346"),
|
110
|
+
("zgw_consumers", "0022_set_default_service_slug"),
|
111
|
+
]
|
112
|
+
|
113
|
+
operations = [
|
114
|
+
migrations.RunPython(
|
115
|
+
migrate_credentials_to_service, reverse_code=migrate_service_to_credentials
|
116
|
+
),
|
117
|
+
migrations.DeleteModel(
|
118
|
+
name="APICredential",
|
119
|
+
),
|
120
|
+
]
|
vng_api_common/mocks.py
CHANGED
vng_api_common/models.py
CHANGED
@@ -1,15 +1,7 @@
|
|
1
|
-
from typing import Optional, Union
|
2
|
-
from urllib.parse import urlsplit, urlunsplit
|
3
|
-
|
4
1
|
from django.db import models
|
5
|
-
from django.db.models.functions import Length
|
6
2
|
from django.utils.translation import gettext_lazy as _
|
7
3
|
|
8
4
|
from rest_framework.reverse import reverse
|
9
|
-
from solo.models import SingletonModel
|
10
|
-
from zds_client import Client, ClientAuth
|
11
|
-
|
12
|
-
from .client import get_client as _get_client
|
13
5
|
|
14
6
|
|
15
7
|
class APIMixin:
|
@@ -34,6 +26,11 @@ class APIMixin:
|
|
34
26
|
return url
|
35
27
|
|
36
28
|
|
29
|
+
class JWTSecretManager(models.Manager):
|
30
|
+
def get_by_natural_key(self, identifier):
|
31
|
+
return self.get(identifier=identifier)
|
32
|
+
|
33
|
+
|
37
34
|
class JWTSecret(models.Model):
|
38
35
|
"""
|
39
36
|
Store credentials of clients that want to access our API.
|
@@ -53,112 +50,14 @@ class JWTSecret(models.Model):
|
|
53
50
|
_("secret"), max_length=255, help_text=_("Secret belonging to the client ID.")
|
54
51
|
)
|
55
52
|
|
53
|
+
objects = JWTSecretManager()
|
54
|
+
|
55
|
+
def natural_key(self):
|
56
|
+
return (self.identifier,)
|
57
|
+
|
56
58
|
class Meta:
|
57
59
|
verbose_name = _("client credential")
|
58
60
|
verbose_name_plural = _("client credentials")
|
59
61
|
|
60
62
|
def __str__(self):
|
61
63
|
return self.identifier
|
62
|
-
|
63
|
-
|
64
|
-
class APICredential(models.Model):
|
65
|
-
"""
|
66
|
-
Store credentials for external APIs.
|
67
|
-
|
68
|
-
When we need to authenticate against a remote API, we need to know which
|
69
|
-
client ID and secret to use to sign the JWT.
|
70
|
-
"""
|
71
|
-
|
72
|
-
api_root = models.URLField(
|
73
|
-
_("API-root"),
|
74
|
-
unique=True,
|
75
|
-
help_text=_(
|
76
|
-
"URL of the external API, ending in a trailing slash. Example: https://example.com/api/v1/"
|
77
|
-
),
|
78
|
-
)
|
79
|
-
label = models.CharField(
|
80
|
-
_("label"),
|
81
|
-
max_length=100,
|
82
|
-
default="",
|
83
|
-
help_text=_("Human readable label of the external API."),
|
84
|
-
)
|
85
|
-
client_id = models.CharField(
|
86
|
-
_("client ID"),
|
87
|
-
max_length=255,
|
88
|
-
help_text=_("Client ID to identify this API at the external API."),
|
89
|
-
)
|
90
|
-
secret = models.CharField(
|
91
|
-
_("secret"), max_length=255, help_text=_("Secret belonging to the client ID.")
|
92
|
-
)
|
93
|
-
user_id = models.CharField(
|
94
|
-
_("user ID"),
|
95
|
-
max_length=255,
|
96
|
-
help_text=_(
|
97
|
-
"User ID to use for the audit trail. Although these external API credentials are typically used by"
|
98
|
-
"this API itself instead of a user, the user ID is required."
|
99
|
-
),
|
100
|
-
)
|
101
|
-
user_representation = models.CharField(
|
102
|
-
_("user representation"),
|
103
|
-
max_length=255,
|
104
|
-
default="",
|
105
|
-
help_text=_("Human readable representation of the user."),
|
106
|
-
)
|
107
|
-
|
108
|
-
class Meta:
|
109
|
-
verbose_name = _("external API credential")
|
110
|
-
verbose_name_plural = _("external API credentials")
|
111
|
-
|
112
|
-
def __str__(self):
|
113
|
-
return self.api_root
|
114
|
-
|
115
|
-
@classmethod
|
116
|
-
def get_auth(cls, url: str, **kwargs) -> Union[ClientAuth, None]:
|
117
|
-
split_url = urlsplit(url)
|
118
|
-
scheme_and_domain = urlunsplit(split_url[:2] + ("", "", ""))
|
119
|
-
|
120
|
-
candidates = (
|
121
|
-
cls.objects.filter(api_root__startswith=scheme_and_domain)
|
122
|
-
.annotate(api_root_length=Length("api_root"))
|
123
|
-
.order_by("-api_root_length")
|
124
|
-
)
|
125
|
-
|
126
|
-
# select the one matching
|
127
|
-
for candidate in candidates.iterator():
|
128
|
-
if url.startswith(candidate.api_root):
|
129
|
-
credentials = candidate
|
130
|
-
break
|
131
|
-
else:
|
132
|
-
return None
|
133
|
-
|
134
|
-
auth = ClientAuth(
|
135
|
-
client_id=credentials.client_id,
|
136
|
-
secret=credentials.secret,
|
137
|
-
user_id=credentials.user_id,
|
138
|
-
user_representation=credentials.user_representation,
|
139
|
-
**kwargs,
|
140
|
-
)
|
141
|
-
return auth
|
142
|
-
|
143
|
-
|
144
|
-
class ClientConfig(SingletonModel):
|
145
|
-
api_root = models.URLField(_("api root"), unique=True)
|
146
|
-
|
147
|
-
class Meta:
|
148
|
-
abstract = True
|
149
|
-
|
150
|
-
def __str__(self):
|
151
|
-
return self.api_root
|
152
|
-
|
153
|
-
def save(self, *args, **kwargs):
|
154
|
-
if not self.api_root.endswith("/"):
|
155
|
-
self.api_root = f"{self.api_root}/"
|
156
|
-
super().save(*args, **kwargs)
|
157
|
-
|
158
|
-
@classmethod
|
159
|
-
def get_client(cls) -> Optional[Client]:
|
160
|
-
"""
|
161
|
-
Construct a client, prepared with the required auth.
|
162
|
-
"""
|
163
|
-
config = cls.get_solo()
|
164
|
-
return _get_client(config.api_root, url_is_api_root=True)
|