django-api-utility 2.0.0__tar.gz → 3.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. django_api_utility-3.1.0/PKG-INFO +317 -0
  2. django_api_utility-3.1.0/README.md +295 -0
  3. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/__init__.py +16 -6
  4. django_api_utility-3.1.0/django_api_utility/admin.py +38 -0
  5. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/domain/__init__.py +0 -2
  6. django_api_utility-3.1.0/django_api_utility/domain/authentication.py +441 -0
  7. django_api_utility-3.1.0/django_api_utility/domain/endpoint_service.py +66 -0
  8. django_api_utility-3.1.0/django_api_utility/domain/secrets.py +119 -0
  9. django_api_utility-3.1.0/django_api_utility/domain/secrets_infisical.py +196 -0
  10. django_api_utility-3.1.0/django_api_utility/domain/templates.py +78 -0
  11. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/domain/types.py +4 -1
  12. django_api_utility-3.1.0/django_api_utility/migrations/0009_authentication_profiles.py +69 -0
  13. django_api_utility-3.1.0/django_api_utility/migrations/0010_align_published_schema.py +21 -0
  14. django_api_utility-3.1.0/django_api_utility/migrations/0011_delete_integrationservicetokenconfig.py +16 -0
  15. django_api_utility-3.1.0/django_api_utility/migrations/0012_remove_externalserviceendpoint_uniq_active_token_endpoint_per_service_flow_and_more.py +21 -0
  16. django_api_utility-3.1.0/django_api_utility/models.py +211 -0
  17. django_api_utility-3.1.0/django_api_utility/orchestration/__init__.py +21 -0
  18. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/orchestration/api_service.py +94 -18
  19. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/tests/test_api_service.py +43 -0
  20. django_api_utility-3.1.0/django_api_utility/tests/test_authentication.py +192 -0
  21. django_api_utility-3.1.0/django_api_utility/tests/test_endpoint_service.py +72 -0
  22. django_api_utility-3.1.0/django_api_utility/tests/test_http_client.py +143 -0
  23. django_api_utility-3.1.0/django_api_utility/tests/test_integration_pipeline.py +72 -0
  24. django_api_utility-3.1.0/django_api_utility/tests/test_models.py +82 -0
  25. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/tests/test_outbound_service.py +3 -1
  26. django_api_utility-3.1.0/django_api_utility/tests/test_secrets_infisical.py +162 -0
  27. django_api_utility-3.1.0/django_api_utility/tests/test_templates_and_secrets.py +121 -0
  28. django_api_utility-3.1.0/django_api_utility/transport/http_client.py +113 -0
  29. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/transport/outbound_service.py +4 -0
  30. django_api_utility-3.1.0/django_api_utility.egg-info/PKG-INFO +317 -0
  31. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility.egg-info/SOURCES.txt +13 -2
  32. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility.egg-info/requires.txt +4 -2
  33. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/pyproject.toml +9 -5
  34. django_api_utility-3.1.0/requirements.txt +6 -0
  35. django_api_utility-2.0.0/PKG-INFO +0 -103
  36. django_api_utility-2.0.0/README.md +0 -82
  37. django_api_utility-2.0.0/django_api_utility/domain/endpoint_service.py +0 -39
  38. django_api_utility-2.0.0/django_api_utility/domain/token_service.py +0 -101
  39. django_api_utility-2.0.0/django_api_utility/models.py +0 -203
  40. django_api_utility-2.0.0/django_api_utility/orchestration/__init__.py +0 -12
  41. django_api_utility-2.0.0/django_api_utility/tests/test_endpoint_service.py +0 -28
  42. django_api_utility-2.0.0/django_api_utility/tests/test_http_client.py +0 -44
  43. django_api_utility-2.0.0/django_api_utility/tests/test_models.py +0 -164
  44. django_api_utility-2.0.0/django_api_utility/tests/test_token_service.py +0 -103
  45. django_api_utility-2.0.0/django_api_utility/transport/http_client.py +0 -85
  46. django_api_utility-2.0.0/django_api_utility.egg-info/PKG-INFO +0 -103
  47. django_api_utility-2.0.0/requirements.txt +0 -5
  48. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/LICENSE +0 -0
  49. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/MANIFEST.in +0 -0
  50. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/config/external_definition_keys.json +0 -0
  51. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/apps.py +0 -0
  52. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/domain/definition_keys.py +0 -0
  53. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/domain/schema_service.py +0 -0
  54. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/exceptions.py +0 -0
  55. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/migrations/0001_initial.py +0 -0
  56. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/migrations/0002_integrationservicetokenconfig_service_ref.py +0 -0
  57. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/migrations/0003_externalserviceendpoint_payload_key_map.py +0 -0
  58. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/migrations/0004_externalserviceendpoint_content_type.py +0 -0
  59. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/migrations/0005_session.py +0 -0
  60. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/migrations/0006_session_drop_policy_add_id_token.py +0 -0
  61. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/migrations/0007_add_token_flow_fields.py +0 -0
  62. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/migrations/0008_backfill_token_flow.py +0 -0
  63. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/migrations/__init__.py +0 -0
  64. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/tests/__init__.py +0 -0
  65. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/tests/settings.py +0 -0
  66. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/tests/test_definition_keys.py +0 -0
  67. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility/transport/__init__.py +0 -0
  68. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility.egg-info/dependency_links.txt +0 -0
  69. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/django_api_utility.egg-info/top_level.txt +0 -0
  70. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/setup.cfg +0 -0
  71. {django_api_utility-2.0.0 → django_api_utility-3.1.0}/setup.py +0 -0
@@ -0,0 +1,317 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-api-utility
3
+ Version: 3.1.0
4
+ Summary: Reusable API integration utility for Django services
5
+ Author: Nexgensis
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/Nexgensis/api-utility
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: Django>=5.2
12
+ Requires-Dist: httpx<1.0,>=0.28
13
+ Requires-Dist: joserfc<2.0,>=1.6
14
+ Requires-Dist: cryptography>=43.0
15
+ Requires-Dist: jsonschema>=4.23
16
+ Requires-Dist: tenacity>=8.2
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7.0; extra == "test"
19
+ Requires-Dist: pytest-django>=4.5; extra == "test"
20
+ Provides-Extra: infisical
21
+ Dynamic: license-file
22
+
23
+ # django-api-utility
24
+
25
+ A reusable Django package for database-configured outbound API integrations. Services and endpoints stay in Django, secret references point to Infisical-injected values, and the request pipeline handles authentication, pooling, retries, validation, caching, refresh, and safe logs.
26
+
27
+ ## Supported authentication
28
+
29
+ | Type | Credential mode | Typical use |
30
+ |---|---|---|
31
+ | `NONE` | Static | Public/internal endpoint without authentication |
32
+ | `API_KEY` | Static | Key sent in a header or query parameter |
33
+ | `BASIC` | Static | Username and password through HTTP Basic |
34
+ | `STATIC_BEARER` | Static | Long-lived bearer token |
35
+ | `OAUTH2_CLIENT_CREDENTIALS` | Dynamic | OAuth/OIDC machine client, including `client_secret_basic`, `client_secret_post`, and public clients |
36
+ | `JWT_BEARER_GRANT` | Dynamic | Signed JWT assertion, including Zitadel service users |
37
+ | `CUSTOM_TOKEN` | Dynamic | Provider-specific JSON, form, or query token endpoint |
38
+ | `SESSION_COOKIE` | Dynamic | Login endpoint that returns one or more cookies |
39
+
40
+ Token requests and normal API requests use a pooled HTTPX client. JWT signing uses the focused `joserfc` package. Full Authlib is not required because token requests are database-templated and its HTTPX client integration is deprecated.
41
+
42
+ File upload support is intentionally outside the current scope.
43
+
44
+ ### Mutual TLS
45
+
46
+ mTLS is a separate, process-wide transport setting - not a per-profile `AuthType` - because it's a client identity the whole service presents, not something that varies per integration. Enable it with `MTLS_ENABLED=True` plus `MTLS_CERT`/`MTLS_KEY`/`MTLS_CA` (file paths); it combines with any `AuthenticationProfile` type above, or `NONE` if the mTLS handshake is the only authentication a given destination needs.
47
+
48
+ ```python
49
+ MTLS_ENABLED = True
50
+ MTLS_CERT = "/run/secrets/client.crt"
51
+ MTLS_KEY = "/run/secrets/client.key"
52
+ MTLS_CA = "/run/secrets/ca.crt"
53
+ ```
54
+
55
+ Verified end-to-end against a real mTLS-requiring server (see `integration/docker/`'s `mtls_resource` service) - earlier builds of `_build_client()` passed `cert=` and `verify=` to `httpx.HTTPTransport` as two separate arguments, which httpx silently drops the client certificate for; it now builds one `ssl.SSLContext` with both the CA and client cert chain loaded together and passes that as `verify`.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install -e .
61
+ # Infisical support needs no extra package (since 3.1.0 it uses Infisical's
62
+ # REST API through httpx); the empty `[infisical]` extra still installs for
63
+ # existing pins.
64
+ ```
65
+
66
+ Add the app and run its migration:
67
+
68
+ ```python
69
+ INSTALLED_APPS = [
70
+ # ...
71
+ "django_api_utility",
72
+ ]
73
+ ```
74
+
75
+ ```bash
76
+ python manage.py migrate django_api_utility
77
+ ```
78
+
79
+ Migration `0009_authentication_profiles` adds `AuthenticationProfile` and optional profile foreign keys to services and endpoints. `IntegrationServiceTokenConfig` is deprecated as of this migration - nothing in `domain`/`orchestration`/`transport` reads or writes it anymore (confirmed: it's reachable only from `admin.py`). It's kept, unused, purely so an already-deployed consumer's existing rows stay migration-compatible and visible in admin; there's no compatibility proxy routing new traffic through it. New integrations should use `AuthenticationProfile` directly.
80
+
81
+ Version `3.0.0` is the next major release after the published `2.0.0`. It changes the response implementation from Requests to HTTPX and adds the authentication-profile architecture, so consumers should treat it as a breaking upgrade.
82
+
83
+ Version `3.1.0` removes the Infisical SDK dependency: `InfisicalSecretProvider` now calls Infisical's REST API directly with httpx (same settings, environment variables and reference formats), keeps one logged-in provider per process with token renewal and a short value cache. `infisicalsdk` and the AWS packages it pulled in (boto3/botocore) are no longer installed; the `[infisical]` extra remains as an empty alias.
84
+
85
+ ## Data ownership
86
+
87
+ Store non-secret integration configuration in `AuthenticationProfile`:
88
+
89
+ - token URL and HTTP method
90
+ - request encoding, headers, query, and body templates
91
+ - response extraction rules
92
+ - target API credential placement
93
+ - JWT claims and algorithm settings
94
+ - timeout, scope, and other provider configuration
95
+ - logical secret references
96
+
97
+ Store values such as API keys, passwords, client secrets, private keys, and static bearer tokens in Infisical. The default provider reads secrets from environment variables, which fits Infisical CLI or Agent injection. A reference can be `MY_SECRET`, `env:MY_SECRET`, or `{"provider": "env", "name": "MY_SECRET"}`.
98
+
99
+ An Infisical provider ships with the package - `django_api_utility.domain.secrets_infisical.InfisicalSecretProvider`. It calls Infisical's REST API directly with httpx (Universal Auth login, then `GET /api/v3/secrets/raw/{name}`) - no Infisical SDK and none of the AWS packages it pulls in. One provider per process: it logs in once, renews the token before it expires or after a 401, and reuses a fetched value for `INFISICAL_CACHE_SECONDS` (default 60). It is selected automatically when `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` are both in the environment, or explicitly:
100
+
101
+ ```python
102
+ API_UTILITY_SECRET_PROVIDER = "django_api_utility.domain.secrets_infisical.InfisicalSecretProvider"
103
+ INFISICAL_PROJECT_ID = "..."
104
+ INFISICAL_ENVIRONMENT_SLUG = "prod" # default "dev"
105
+ # INFISICAL_HOST defaults to https://app.infisical.com; set it for self-hosted.
106
+ # INFISICAL_CLIENT_ID / INFISICAL_CLIENT_SECRET (machine identity) come from
107
+ # the environment, same as any other deployment secret - never from settings.py.
108
+ # Optional: INFISICAL_CACHE_SECONDS (60), INFISICAL_TIMEOUT_SECONDS (10),
109
+ # INFISICAL_VERIFY (True / False / path to a CA bundle for a private CA).
110
+ ```
111
+
112
+ For anything else, implement an object with `get(reference) -> str` and configure its dotted path the same way:
113
+
114
+ ```python
115
+ API_UTILITY_SECRET_PROVIDER = "my_project.secrets.CustomProvider"
116
+ ```
117
+
118
+ The package does not store retrieved secret values in its models or logs, and its error messages name secrets and paths but never include their values.
119
+
120
+ ## Template variables
121
+
122
+ The request fields use a restricted placeholder resolver. It performs value substitution only and never evaluates Python or general template expressions.
123
+
124
+ ```text
125
+ ${secret.*} -> secret provider using secret_references
126
+ ${config.*} -> AuthenticationProfile.configuration
127
+ ${generated.*} -> generated JWT assertion and similar internal values
128
+ ${runtime.*} -> values passed by the caller for this request
129
+ ```
130
+
131
+ An exact placeholder keeps its native JSON type. A placeholder embedded in a longer string is converted to text.
132
+
133
+ ## Configuration examples
134
+
135
+ A static API key in a header:
136
+
137
+ ```python
138
+ AuthenticationProfile.objects.create(
139
+ name="vendor-api-key",
140
+ auth_type="API_KEY",
141
+ credential_mode="STATIC",
142
+ secret_references={"api_key": "VENDOR_API_KEY"},
143
+ target_auth_config={
144
+ "location": "header",
145
+ "name": "X-API-Key",
146
+ "format": "{token}",
147
+ },
148
+ )
149
+ ```
150
+
151
+ OAuth2 client credentials:
152
+
153
+ ```python
154
+ AuthenticationProfile.objects.create(
155
+ name="vendor-oauth",
156
+ auth_type="OAUTH2_CLIENT_CREDENTIALS",
157
+ credential_mode="DYNAMIC",
158
+ token_url="https://identity.example.com/oauth/token",
159
+ configuration={
160
+ "client_id": "machine-client",
161
+ "token_endpoint_auth_method": "client_secret_post",
162
+ "scope": ["read", "write"],
163
+ "token_timeout_seconds": 10,
164
+ },
165
+ secret_references={"client_secret": "VENDOR_CLIENT_SECRET"},
166
+ )
167
+ ```
168
+
169
+ The code supplies the normal client-credentials request and response defaults: form encoding, `grant_type=client_credentials`, `access_token`, `token_type`, `expires_in`, and `Authorization: Bearer ...`. Database values override these defaults.
170
+
171
+ A Zitadel-style JWT bearer grant:
172
+
173
+ ```python
174
+ AuthenticationProfile.objects.create(
175
+ name="zitadel-service-user",
176
+ auth_type="JWT_BEARER_GRANT",
177
+ credential_mode="DYNAMIC",
178
+ token_url="https://identity.example.com/oauth/v2/token",
179
+ configuration={
180
+ "client_id": "service-user-id",
181
+ "scope": ["openid", "urn:zitadel:iam:org:project:id:aud"],
182
+ },
183
+ secret_references={"private_key": "ZITADEL_PRIVATE_KEY_PEM"},
184
+ jwt_config={
185
+ "issuer": "service-user-id",
186
+ "subject": "service-user-id",
187
+ "audience": "https://identity.example.com",
188
+ "key_id": "key-id",
189
+ "algorithm": "RS256",
190
+ "lifetime_seconds": 300,
191
+ },
192
+ )
193
+ ```
194
+
195
+ A custom token response and runtime tenant:
196
+
197
+ ```python
198
+ AuthenticationProfile.objects.create(
199
+ name="tenant-session",
200
+ auth_type="CUSTOM_TOKEN",
201
+ credential_mode="DYNAMIC",
202
+ token_url="https://login.example.com/${runtime.tenant}/token",
203
+ request_encoding="JSON",
204
+ request_body={
205
+ "username": "${config.username}",
206
+ "password": "${secret.password}",
207
+ },
208
+ configuration={"username": "integration-user"},
209
+ secret_references={"password": "TENANT_API_PASSWORD"},
210
+ response_config={
211
+ "token_source": "json",
212
+ "token_path": "data.access.token",
213
+ "expires_in_path": "data.expires_in",
214
+ },
215
+ target_auth_config={
216
+ "location": "header",
217
+ "name": "Authorization",
218
+ "format": "Bearer {token}",
219
+ },
220
+ )
221
+ ```
222
+
223
+ Attach a profile to `ExternalServiceConfig` for all endpoints. Set `ExternalServiceEndpoint.authentication_profile` to override it for one endpoint.
224
+
225
+ ## Calling endpoints
226
+
227
+ Provide the existing definition-key JSON mapping through `EXTERNAL_DEFINITION_KEYS_FILE`, then call:
228
+
229
+ ```python
230
+ from django_api_utility import get_by_key, post_by_key
231
+
232
+ response = get_by_key("users_get", params={"page": 1})
233
+ created = post_by_key(
234
+ "users_create",
235
+ payload={"name": "Alice"},
236
+ auth_runtime={"tenant": "acme"},
237
+ )
238
+ ```
239
+
240
+ `post_by_key`, `put_by_key`, `patch_by_key`, and `request_by_key` continue to accept `form_data`. Existing endpoint fields such as `content_type`, `payload_key_map`, and `token_flow` remain available for compatibility with 2.x registry data.
241
+
242
+ Authentication query parameters take precedence over caller query parameters so a caller cannot replace a configured credential.
243
+
244
+ ## Caching and rotation
245
+
246
+ Static keys and passwords are read from the secret provider for each resolved request, so an Infisical rotation is used on the next call. Dynamic tokens and cookies are cached until their reported expiry minus `refresh_buffer_seconds`; a 401 invalidates the cached value, reads current secrets again, and retries once.
247
+
248
+ Use a shared Django cache such as Redis in multi-worker production deployments. A short cache lock limits simultaneous token fetches across workers, while an in-process lock covers threads. Runtime template values are included in the cache key to prevent credentials from being reused across tenants.
249
+
250
+ ```python
251
+ API_UTILITY_AUTH_CACHE_ALIAS = "default"
252
+ API_UTILITY_TOKEN_TIMEOUT_SECONDS = 10
253
+ API_UTILITY_REQUIRE_HTTPS = True
254
+ API_UTILITY_ALLOWED_AUTH_HOSTS = ["identity.example.com", "vendor.example.com"]
255
+ API_UTILITY_JWT_ALGORITHMS = ["RS256", "ES256"]
256
+ ```
257
+
258
+ Existing 2.x services without an authentication profile continue to obtain their bearer token through `API_UTILITY_MACHINE_TOKEN_DEFINITION_KEY`. Assigning a profile switches that service or endpoint to the new direct, template-driven authentication flow.
259
+
260
+ ## Logging and failure behavior
261
+
262
+ The package logs request lifecycle events, upstream status, retry attempts, token refresh results, timeouts, and transport failures with module loggers under `django_api_utility`. Authorization, cookie, and API-key header values are redacted, and token response bodies are not included in errors. Inactive authentication profiles fail closed.
263
+
264
+ A typical Django logging configuration is:
265
+
266
+ ```python
267
+ LOGGING = {
268
+ "version": 1,
269
+ "disable_existing_loggers": False,
270
+ "handlers": {"console": {"class": "logging.StreamHandler"}},
271
+ "loggers": {
272
+ "django_api_utility": {
273
+ "handlers": ["console"],
274
+ "level": "INFO",
275
+ "propagate": False,
276
+ },
277
+ },
278
+ }
279
+ ```
280
+
281
+ Safe HTTP methods retry configured transient status codes with exponential jitter. Transport errors become typed utility exceptions, request and response JSON schemas remain supported, and a 401 triggers one credential refresh before the response returns.
282
+
283
+ ## Tests
284
+
285
+ ```bash
286
+ pip install -e '.[test]'
287
+ pytest -q
288
+ python -m django makemigrations django_api_utility --check --dry-run
289
+ ```
290
+
291
+ The four-service Docker integration harness runs an authentication provider,
292
+ an independent protected resource API, a client-certificate-required mTLS
293
+ resource, and a consumer Django project:
294
+
295
+ ```bash
296
+ sh integration/docker/generate_test_credentials.sh
297
+ docker compose -f integration/docker/compose.yaml up \
298
+ --build --abort-on-container-exit --exit-code-from consumer
299
+ ```
300
+
301
+ It covers every supported `AuthenticationProfile` type including JWT bearer
302
+ grant, mTLS (a real client-certificate handshake against `mtls_resource`,
303
+ not a mock), all five request methods, local-memory token caching, endpoint
304
+ overrides, runtime templates, cookies and CSRF, and forced refresh following
305
+ a 401. Zitadel itself and the Infisical secret provider aren't part of this
306
+ harness - the latter is heavy enough (a full self-hosted instance plus
307
+ bootstrap) that it's verified separately; see
308
+ `integration/docker/INFISICAL_VERIFICATION.md` for the reproducible steps.
309
+ See `integration/docker/README.md` for cleanup instructions.
310
+
311
+ ## 3.0.0 upgrade notes
312
+
313
+ - The distribution and runtime response type move from Requests to HTTPX.
314
+ - Apply migrations `0009` and `0010` after taking the normal database backup. Migration `0010` aligns the database with the 2.0 model state by removing the obsolete session-tracking table and preserving the nullable endpoint content-type behavior.
315
+ - Services without a new authentication profile continue through the 2.x machine-token proxy flow.
316
+ - Assign and test authentication profiles before disabling the legacy proxy configuration.
317
+ - `PyJWT` is no longer required; JWT assertions use `joserfc`.
@@ -0,0 +1,295 @@
1
+ # django-api-utility
2
+
3
+ A reusable Django package for database-configured outbound API integrations. Services and endpoints stay in Django, secret references point to Infisical-injected values, and the request pipeline handles authentication, pooling, retries, validation, caching, refresh, and safe logs.
4
+
5
+ ## Supported authentication
6
+
7
+ | Type | Credential mode | Typical use |
8
+ |---|---|---|
9
+ | `NONE` | Static | Public/internal endpoint without authentication |
10
+ | `API_KEY` | Static | Key sent in a header or query parameter |
11
+ | `BASIC` | Static | Username and password through HTTP Basic |
12
+ | `STATIC_BEARER` | Static | Long-lived bearer token |
13
+ | `OAUTH2_CLIENT_CREDENTIALS` | Dynamic | OAuth/OIDC machine client, including `client_secret_basic`, `client_secret_post`, and public clients |
14
+ | `JWT_BEARER_GRANT` | Dynamic | Signed JWT assertion, including Zitadel service users |
15
+ | `CUSTOM_TOKEN` | Dynamic | Provider-specific JSON, form, or query token endpoint |
16
+ | `SESSION_COOKIE` | Dynamic | Login endpoint that returns one or more cookies |
17
+
18
+ Token requests and normal API requests use a pooled HTTPX client. JWT signing uses the focused `joserfc` package. Full Authlib is not required because token requests are database-templated and its HTTPX client integration is deprecated.
19
+
20
+ File upload support is intentionally outside the current scope.
21
+
22
+ ### Mutual TLS
23
+
24
+ mTLS is a separate, process-wide transport setting - not a per-profile `AuthType` - because it's a client identity the whole service presents, not something that varies per integration. Enable it with `MTLS_ENABLED=True` plus `MTLS_CERT`/`MTLS_KEY`/`MTLS_CA` (file paths); it combines with any `AuthenticationProfile` type above, or `NONE` if the mTLS handshake is the only authentication a given destination needs.
25
+
26
+ ```python
27
+ MTLS_ENABLED = True
28
+ MTLS_CERT = "/run/secrets/client.crt"
29
+ MTLS_KEY = "/run/secrets/client.key"
30
+ MTLS_CA = "/run/secrets/ca.crt"
31
+ ```
32
+
33
+ Verified end-to-end against a real mTLS-requiring server (see `integration/docker/`'s `mtls_resource` service) - earlier builds of `_build_client()` passed `cert=` and `verify=` to `httpx.HTTPTransport` as two separate arguments, which httpx silently drops the client certificate for; it now builds one `ssl.SSLContext` with both the CA and client cert chain loaded together and passes that as `verify`.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install -e .
39
+ # Infisical support needs no extra package (since 3.1.0 it uses Infisical's
40
+ # REST API through httpx); the empty `[infisical]` extra still installs for
41
+ # existing pins.
42
+ ```
43
+
44
+ Add the app and run its migration:
45
+
46
+ ```python
47
+ INSTALLED_APPS = [
48
+ # ...
49
+ "django_api_utility",
50
+ ]
51
+ ```
52
+
53
+ ```bash
54
+ python manage.py migrate django_api_utility
55
+ ```
56
+
57
+ Migration `0009_authentication_profiles` adds `AuthenticationProfile` and optional profile foreign keys to services and endpoints. `IntegrationServiceTokenConfig` is deprecated as of this migration - nothing in `domain`/`orchestration`/`transport` reads or writes it anymore (confirmed: it's reachable only from `admin.py`). It's kept, unused, purely so an already-deployed consumer's existing rows stay migration-compatible and visible in admin; there's no compatibility proxy routing new traffic through it. New integrations should use `AuthenticationProfile` directly.
58
+
59
+ Version `3.0.0` is the next major release after the published `2.0.0`. It changes the response implementation from Requests to HTTPX and adds the authentication-profile architecture, so consumers should treat it as a breaking upgrade.
60
+
61
+ Version `3.1.0` removes the Infisical SDK dependency: `InfisicalSecretProvider` now calls Infisical's REST API directly with httpx (same settings, environment variables and reference formats), keeps one logged-in provider per process with token renewal and a short value cache. `infisicalsdk` and the AWS packages it pulled in (boto3/botocore) are no longer installed; the `[infisical]` extra remains as an empty alias.
62
+
63
+ ## Data ownership
64
+
65
+ Store non-secret integration configuration in `AuthenticationProfile`:
66
+
67
+ - token URL and HTTP method
68
+ - request encoding, headers, query, and body templates
69
+ - response extraction rules
70
+ - target API credential placement
71
+ - JWT claims and algorithm settings
72
+ - timeout, scope, and other provider configuration
73
+ - logical secret references
74
+
75
+ Store values such as API keys, passwords, client secrets, private keys, and static bearer tokens in Infisical. The default provider reads secrets from environment variables, which fits Infisical CLI or Agent injection. A reference can be `MY_SECRET`, `env:MY_SECRET`, or `{"provider": "env", "name": "MY_SECRET"}`.
76
+
77
+ An Infisical provider ships with the package - `django_api_utility.domain.secrets_infisical.InfisicalSecretProvider`. It calls Infisical's REST API directly with httpx (Universal Auth login, then `GET /api/v3/secrets/raw/{name}`) - no Infisical SDK and none of the AWS packages it pulls in. One provider per process: it logs in once, renews the token before it expires or after a 401, and reuses a fetched value for `INFISICAL_CACHE_SECONDS` (default 60). It is selected automatically when `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET` are both in the environment, or explicitly:
78
+
79
+ ```python
80
+ API_UTILITY_SECRET_PROVIDER = "django_api_utility.domain.secrets_infisical.InfisicalSecretProvider"
81
+ INFISICAL_PROJECT_ID = "..."
82
+ INFISICAL_ENVIRONMENT_SLUG = "prod" # default "dev"
83
+ # INFISICAL_HOST defaults to https://app.infisical.com; set it for self-hosted.
84
+ # INFISICAL_CLIENT_ID / INFISICAL_CLIENT_SECRET (machine identity) come from
85
+ # the environment, same as any other deployment secret - never from settings.py.
86
+ # Optional: INFISICAL_CACHE_SECONDS (60), INFISICAL_TIMEOUT_SECONDS (10),
87
+ # INFISICAL_VERIFY (True / False / path to a CA bundle for a private CA).
88
+ ```
89
+
90
+ For anything else, implement an object with `get(reference) -> str` and configure its dotted path the same way:
91
+
92
+ ```python
93
+ API_UTILITY_SECRET_PROVIDER = "my_project.secrets.CustomProvider"
94
+ ```
95
+
96
+ The package does not store retrieved secret values in its models or logs, and its error messages name secrets and paths but never include their values.
97
+
98
+ ## Template variables
99
+
100
+ The request fields use a restricted placeholder resolver. It performs value substitution only and never evaluates Python or general template expressions.
101
+
102
+ ```text
103
+ ${secret.*} -> secret provider using secret_references
104
+ ${config.*} -> AuthenticationProfile.configuration
105
+ ${generated.*} -> generated JWT assertion and similar internal values
106
+ ${runtime.*} -> values passed by the caller for this request
107
+ ```
108
+
109
+ An exact placeholder keeps its native JSON type. A placeholder embedded in a longer string is converted to text.
110
+
111
+ ## Configuration examples
112
+
113
+ A static API key in a header:
114
+
115
+ ```python
116
+ AuthenticationProfile.objects.create(
117
+ name="vendor-api-key",
118
+ auth_type="API_KEY",
119
+ credential_mode="STATIC",
120
+ secret_references={"api_key": "VENDOR_API_KEY"},
121
+ target_auth_config={
122
+ "location": "header",
123
+ "name": "X-API-Key",
124
+ "format": "{token}",
125
+ },
126
+ )
127
+ ```
128
+
129
+ OAuth2 client credentials:
130
+
131
+ ```python
132
+ AuthenticationProfile.objects.create(
133
+ name="vendor-oauth",
134
+ auth_type="OAUTH2_CLIENT_CREDENTIALS",
135
+ credential_mode="DYNAMIC",
136
+ token_url="https://identity.example.com/oauth/token",
137
+ configuration={
138
+ "client_id": "machine-client",
139
+ "token_endpoint_auth_method": "client_secret_post",
140
+ "scope": ["read", "write"],
141
+ "token_timeout_seconds": 10,
142
+ },
143
+ secret_references={"client_secret": "VENDOR_CLIENT_SECRET"},
144
+ )
145
+ ```
146
+
147
+ The code supplies the normal client-credentials request and response defaults: form encoding, `grant_type=client_credentials`, `access_token`, `token_type`, `expires_in`, and `Authorization: Bearer ...`. Database values override these defaults.
148
+
149
+ A Zitadel-style JWT bearer grant:
150
+
151
+ ```python
152
+ AuthenticationProfile.objects.create(
153
+ name="zitadel-service-user",
154
+ auth_type="JWT_BEARER_GRANT",
155
+ credential_mode="DYNAMIC",
156
+ token_url="https://identity.example.com/oauth/v2/token",
157
+ configuration={
158
+ "client_id": "service-user-id",
159
+ "scope": ["openid", "urn:zitadel:iam:org:project:id:aud"],
160
+ },
161
+ secret_references={"private_key": "ZITADEL_PRIVATE_KEY_PEM"},
162
+ jwt_config={
163
+ "issuer": "service-user-id",
164
+ "subject": "service-user-id",
165
+ "audience": "https://identity.example.com",
166
+ "key_id": "key-id",
167
+ "algorithm": "RS256",
168
+ "lifetime_seconds": 300,
169
+ },
170
+ )
171
+ ```
172
+
173
+ A custom token response and runtime tenant:
174
+
175
+ ```python
176
+ AuthenticationProfile.objects.create(
177
+ name="tenant-session",
178
+ auth_type="CUSTOM_TOKEN",
179
+ credential_mode="DYNAMIC",
180
+ token_url="https://login.example.com/${runtime.tenant}/token",
181
+ request_encoding="JSON",
182
+ request_body={
183
+ "username": "${config.username}",
184
+ "password": "${secret.password}",
185
+ },
186
+ configuration={"username": "integration-user"},
187
+ secret_references={"password": "TENANT_API_PASSWORD"},
188
+ response_config={
189
+ "token_source": "json",
190
+ "token_path": "data.access.token",
191
+ "expires_in_path": "data.expires_in",
192
+ },
193
+ target_auth_config={
194
+ "location": "header",
195
+ "name": "Authorization",
196
+ "format": "Bearer {token}",
197
+ },
198
+ )
199
+ ```
200
+
201
+ Attach a profile to `ExternalServiceConfig` for all endpoints. Set `ExternalServiceEndpoint.authentication_profile` to override it for one endpoint.
202
+
203
+ ## Calling endpoints
204
+
205
+ Provide the existing definition-key JSON mapping through `EXTERNAL_DEFINITION_KEYS_FILE`, then call:
206
+
207
+ ```python
208
+ from django_api_utility import get_by_key, post_by_key
209
+
210
+ response = get_by_key("users_get", params={"page": 1})
211
+ created = post_by_key(
212
+ "users_create",
213
+ payload={"name": "Alice"},
214
+ auth_runtime={"tenant": "acme"},
215
+ )
216
+ ```
217
+
218
+ `post_by_key`, `put_by_key`, `patch_by_key`, and `request_by_key` continue to accept `form_data`. Existing endpoint fields such as `content_type`, `payload_key_map`, and `token_flow` remain available for compatibility with 2.x registry data.
219
+
220
+ Authentication query parameters take precedence over caller query parameters so a caller cannot replace a configured credential.
221
+
222
+ ## Caching and rotation
223
+
224
+ Static keys and passwords are read from the secret provider for each resolved request, so an Infisical rotation is used on the next call. Dynamic tokens and cookies are cached until their reported expiry minus `refresh_buffer_seconds`; a 401 invalidates the cached value, reads current secrets again, and retries once.
225
+
226
+ Use a shared Django cache such as Redis in multi-worker production deployments. A short cache lock limits simultaneous token fetches across workers, while an in-process lock covers threads. Runtime template values are included in the cache key to prevent credentials from being reused across tenants.
227
+
228
+ ```python
229
+ API_UTILITY_AUTH_CACHE_ALIAS = "default"
230
+ API_UTILITY_TOKEN_TIMEOUT_SECONDS = 10
231
+ API_UTILITY_REQUIRE_HTTPS = True
232
+ API_UTILITY_ALLOWED_AUTH_HOSTS = ["identity.example.com", "vendor.example.com"]
233
+ API_UTILITY_JWT_ALGORITHMS = ["RS256", "ES256"]
234
+ ```
235
+
236
+ Existing 2.x services without an authentication profile continue to obtain their bearer token through `API_UTILITY_MACHINE_TOKEN_DEFINITION_KEY`. Assigning a profile switches that service or endpoint to the new direct, template-driven authentication flow.
237
+
238
+ ## Logging and failure behavior
239
+
240
+ The package logs request lifecycle events, upstream status, retry attempts, token refresh results, timeouts, and transport failures with module loggers under `django_api_utility`. Authorization, cookie, and API-key header values are redacted, and token response bodies are not included in errors. Inactive authentication profiles fail closed.
241
+
242
+ A typical Django logging configuration is:
243
+
244
+ ```python
245
+ LOGGING = {
246
+ "version": 1,
247
+ "disable_existing_loggers": False,
248
+ "handlers": {"console": {"class": "logging.StreamHandler"}},
249
+ "loggers": {
250
+ "django_api_utility": {
251
+ "handlers": ["console"],
252
+ "level": "INFO",
253
+ "propagate": False,
254
+ },
255
+ },
256
+ }
257
+ ```
258
+
259
+ Safe HTTP methods retry configured transient status codes with exponential jitter. Transport errors become typed utility exceptions, request and response JSON schemas remain supported, and a 401 triggers one credential refresh before the response returns.
260
+
261
+ ## Tests
262
+
263
+ ```bash
264
+ pip install -e '.[test]'
265
+ pytest -q
266
+ python -m django makemigrations django_api_utility --check --dry-run
267
+ ```
268
+
269
+ The four-service Docker integration harness runs an authentication provider,
270
+ an independent protected resource API, a client-certificate-required mTLS
271
+ resource, and a consumer Django project:
272
+
273
+ ```bash
274
+ sh integration/docker/generate_test_credentials.sh
275
+ docker compose -f integration/docker/compose.yaml up \
276
+ --build --abort-on-container-exit --exit-code-from consumer
277
+ ```
278
+
279
+ It covers every supported `AuthenticationProfile` type including JWT bearer
280
+ grant, mTLS (a real client-certificate handshake against `mtls_resource`,
281
+ not a mock), all five request methods, local-memory token caching, endpoint
282
+ overrides, runtime templates, cookies and CSRF, and forced refresh following
283
+ a 401. Zitadel itself and the Infisical secret provider aren't part of this
284
+ harness - the latter is heavy enough (a full self-hosted instance plus
285
+ bootstrap) that it's verified separately; see
286
+ `integration/docker/INFISICAL_VERIFICATION.md` for the reproducible steps.
287
+ See `integration/docker/README.md` for cleanup instructions.
288
+
289
+ ## 3.0.0 upgrade notes
290
+
291
+ - The distribution and runtime response type move from Requests to HTTPX.
292
+ - Apply migrations `0009` and `0010` after taking the normal database backup. Migration `0010` aligns the database with the 2.0 model state by removing the obsolete session-tracking table and preserving the nullable endpoint content-type behavior.
293
+ - Services without a new authentication profile continue through the 2.x machine-token proxy flow.
294
+ - Assign and test authentication profiles before disabling the legacy proxy configuration.
295
+ - `PyJWT` is no longer required; JWT assertions use `joserfc`.