stapel-core 0.3.2__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 (200) hide show
  1. stapel_core-0.3.2/LICENSE +21 -0
  2. stapel_core-0.3.2/PKG-INFO +294 -0
  3. stapel_core-0.3.2/README.md +255 -0
  4. stapel_core-0.3.2/__init__.py +101 -0
  5. stapel_core-0.3.2/bus/__init__.py +35 -0
  6. stapel_core-0.3.2/bus/_config.py +81 -0
  7. stapel_core-0.3.2/bus/backends/__init__.py +0 -0
  8. stapel_core-0.3.2/bus/backends/kafka.py +194 -0
  9. stapel_core-0.3.2/bus/backends/memory.py +84 -0
  10. stapel_core-0.3.2/bus/backends/nats.py +282 -0
  11. stapel_core-0.3.2/bus/backends/routing.py +140 -0
  12. stapel_core-0.3.2/bus/base.py +36 -0
  13. stapel_core-0.3.2/bus/consumer.py +44 -0
  14. stapel_core-0.3.2/bus/event.py +57 -0
  15. stapel_core-0.3.2/bus/router.py +68 -0
  16. stapel_core-0.3.2/captcha/__init__.py +35 -0
  17. stapel_core-0.3.2/captcha/backends.py +159 -0
  18. stapel_core-0.3.2/captcha/conf.py +37 -0
  19. stapel_core-0.3.2/captcha/policy.py +168 -0
  20. stapel_core-0.3.2/comm/__init__.py +76 -0
  21. stapel_core-0.3.2/comm/actions.py +135 -0
  22. stapel_core-0.3.2/comm/config.py +72 -0
  23. stapel_core-0.3.2/comm/exceptions.py +34 -0
  24. stapel_core-0.3.2/comm/functions.py +186 -0
  25. stapel_core-0.3.2/comm/http.py +62 -0
  26. stapel_core-0.3.2/comm/nats.py +144 -0
  27. stapel_core-0.3.2/comm/registry.py +120 -0
  28. stapel_core-0.3.2/comm/schemas.py +66 -0
  29. stapel_core-0.3.2/comm/tasks.py +333 -0
  30. stapel_core-0.3.2/conf.py +90 -0
  31. stapel_core-0.3.2/core/__init__.py +18 -0
  32. stapel_core-0.3.2/core/config.py +174 -0
  33. stapel_core-0.3.2/core/jwt_handler.py +448 -0
  34. stapel_core-0.3.2/core/language.py +125 -0
  35. stapel_core-0.3.2/core/token_blacklist.py +63 -0
  36. stapel_core-0.3.2/core/token_manager.py +221 -0
  37. stapel_core-0.3.2/django/__init__.py +58 -0
  38. stapel_core-0.3.2/django/admin/__init__.py +1 -0
  39. stapel_core-0.3.2/django/admin/context.py +53 -0
  40. stapel_core-0.3.2/django/admin/mixins.py +149 -0
  41. stapel_core-0.3.2/django/admin/redirect.py +63 -0
  42. stapel_core-0.3.2/django/api/__init__.py +1 -0
  43. stapel_core-0.3.2/django/api/errors.py +458 -0
  44. stapel_core-0.3.2/django/api/pagination.py +336 -0
  45. stapel_core-0.3.2/django/api/permissions.py +162 -0
  46. stapel_core-0.3.2/django/api/revision.py +452 -0
  47. stapel_core-0.3.2/django/api/routers.py +10 -0
  48. stapel_core-0.3.2/django/api/serializers.py +262 -0
  49. stapel_core-0.3.2/django/apps.py +57 -0
  50. stapel_core-0.3.2/django/authentication.py +3 -0
  51. stapel_core-0.3.2/django/captcha.py +280 -0
  52. stapel_core-0.3.2/django/cdn/__init__.py +1 -0
  53. stapel_core-0.3.2/django/cdn/fields.py +295 -0
  54. stapel_core-0.3.2/django/cdn/ref_sync.py +119 -0
  55. stapel_core-0.3.2/django/errors.py +59 -0
  56. stapel_core-0.3.2/django/groups.py +212 -0
  57. stapel_core-0.3.2/django/jwt/__init__.py +1 -0
  58. stapel_core-0.3.2/django/jwt/authentication.py +181 -0
  59. stapel_core-0.3.2/django/jwt/backends.py +73 -0
  60. stapel_core-0.3.2/django/jwt/login_views.py +92 -0
  61. stapel_core-0.3.2/django/jwt/middleware.py +333 -0
  62. stapel_core-0.3.2/django/jwt/provider.py +191 -0
  63. stapel_core-0.3.2/django/jwt/session.py +59 -0
  64. stapel_core-0.3.2/django/jwt/utils.py +672 -0
  65. stapel_core-0.3.2/django/jwt/views.py +181 -0
  66. stapel_core-0.3.2/django/jwt_provider.py +2 -0
  67. stapel_core-0.3.2/django/management/__init__.py +0 -0
  68. stapel_core-0.3.2/django/management/commands/__init__.py +0 -0
  69. stapel_core-0.3.2/django/management/commands/check_flows.py +34 -0
  70. stapel_core-0.3.2/django/management/commands/consume_actions.py +57 -0
  71. stapel_core-0.3.2/django/management/commands/generate_flow_docs.py +39 -0
  72. stapel_core-0.3.2/django/management/commands/reset_sequences.py +124 -0
  73. stapel_core-0.3.2/django/management/commands/serve_functions.py +89 -0
  74. stapel_core-0.3.2/django/management/commands/staff_group.py +174 -0
  75. stapel_core-0.3.2/django/models.py +213 -0
  76. stapel_core-0.3.2/django/monitoring/__init__.py +1 -0
  77. stapel_core-0.3.2/django/monitoring/health.py +177 -0
  78. stapel_core-0.3.2/django/monitoring/telegram.py +114 -0
  79. stapel_core-0.3.2/django/openapi/__init__.py +7 -0
  80. stapel_core-0.3.2/django/openapi/extensions.py +106 -0
  81. stapel_core-0.3.2/django/openapi/mcp.py +164 -0
  82. stapel_core-0.3.2/django/openapi/openid.py +91 -0
  83. stapel_core-0.3.2/django/openapi/schemas.py +399 -0
  84. stapel_core-0.3.2/django/openapi/swagger.py +494 -0
  85. stapel_core-0.3.2/django/outbox/__init__.py +0 -0
  86. stapel_core-0.3.2/django/outbox/apps.py +8 -0
  87. stapel_core-0.3.2/django/outbox/management/__init__.py +0 -0
  88. stapel_core-0.3.2/django/outbox/management/commands/__init__.py +0 -0
  89. stapel_core-0.3.2/django/outbox/management/commands/dispatch_outbox.py +31 -0
  90. stapel_core-0.3.2/django/outbox/migrations/0001_initial.py +31 -0
  91. stapel_core-0.3.2/django/outbox/migrations/__init__.py +0 -0
  92. stapel_core-0.3.2/django/outbox/models.py +31 -0
  93. stapel_core-0.3.2/django/outbox/relay.py +72 -0
  94. stapel_core-0.3.2/django/settings.py +510 -0
  95. stapel_core-0.3.2/django/taskstore/__init__.py +0 -0
  96. stapel_core-0.3.2/django/taskstore/apps.py +22 -0
  97. stapel_core-0.3.2/django/taskstore/management/__init__.py +0 -0
  98. stapel_core-0.3.2/django/taskstore/management/commands/__init__.py +0 -0
  99. stapel_core-0.3.2/django/taskstore/management/commands/sweep_tasks.py +39 -0
  100. stapel_core-0.3.2/django/taskstore/migrations/0001_initial.py +37 -0
  101. stapel_core-0.3.2/django/taskstore/migrations/__init__.py +0 -0
  102. stapel_core-0.3.2/django/taskstore/models.py +42 -0
  103. stapel_core-0.3.2/django/templates/admin/base_site.html +229 -0
  104. stapel_core-0.3.2/django/users/__init__.py +1 -0
  105. stapel_core-0.3.2/django/users/admin.py +6 -0
  106. stapel_core-0.3.2/django/users/apps.py +8 -0
  107. stapel_core-0.3.2/django/users/migrations/0001_initial.py +62 -0
  108. stapel_core-0.3.2/django/users/migrations/0002_alter_user_managers_alter_user_groups_and_more.py +37 -0
  109. stapel_core-0.3.2/django/users/migrations/0003_remove_unique_constraints.py +23 -0
  110. stapel_core-0.3.2/django/users/migrations/0004_alter_user_phone.py +36 -0
  111. stapel_core-0.3.2/django/users/migrations/0005_alter_user_groups_alter_user_user_permissions.py +24 -0
  112. stapel_core-0.3.2/django/users/migrations/__init__.py +2 -0
  113. stapel_core-0.3.2/django/users/models.py +152 -0
  114. stapel_core-0.3.2/django/utils.py +8 -0
  115. stapel_core-0.3.2/django/workspaces.py +132 -0
  116. stapel_core-0.3.2/flows/__init__.py +38 -0
  117. stapel_core-0.3.2/flows/checks.py +116 -0
  118. stapel_core-0.3.2/flows/docs.py +192 -0
  119. stapel_core-0.3.2/flows/registry.py +169 -0
  120. stapel_core-0.3.2/gdpr.py +197 -0
  121. stapel_core-0.3.2/kafka/__init__.py +34 -0
  122. stapel_core-0.3.2/kafka/config.py +63 -0
  123. stapel_core-0.3.2/kafka/consumer.py +234 -0
  124. stapel_core-0.3.2/kafka/events.py +63 -0
  125. stapel_core-0.3.2/kafka/health.py +36 -0
  126. stapel_core-0.3.2/kafka/producer.py +105 -0
  127. stapel_core-0.3.2/kafka/topics.py +39 -0
  128. stapel_core-0.3.2/netintel/__init__.py +160 -0
  129. stapel_core-0.3.2/netintel/checks.py +47 -0
  130. stapel_core-0.3.2/netintel/conf.py +38 -0
  131. stapel_core-0.3.2/netintel/providers.py +314 -0
  132. stapel_core-0.3.2/netintel/types.py +47 -0
  133. stapel_core-0.3.2/notifications/__init__.py +17 -0
  134. stapel_core-0.3.2/notifications/publish.py +90 -0
  135. stapel_core-0.3.2/notifications/schemas/emits/notification.requested.json +18 -0
  136. stapel_core-0.3.2/notifications/tokens.py +59 -0
  137. stapel_core-0.3.2/oauth.py +112 -0
  138. stapel_core-0.3.2/py.typed +0 -0
  139. stapel_core-0.3.2/pyproject.toml +142 -0
  140. stapel_core-0.3.2/setup.cfg +4 -0
  141. stapel_core-0.3.2/signals.py +48 -0
  142. stapel_core-0.3.2/stapel_core.egg-info/PKG-INFO +294 -0
  143. stapel_core-0.3.2/stapel_core.egg-info/SOURCES.txt +344 -0
  144. stapel_core-0.3.2/stapel_core.egg-info/dependency_links.txt +1 -0
  145. stapel_core-0.3.2/stapel_core.egg-info/requires.txt +30 -0
  146. stapel_core-0.3.2/stapel_core.egg-info/top_level.txt +1 -0
  147. stapel_core-0.3.2/static/admin/js/cdn_image_widget.js +955 -0
  148. stapel_core-0.3.2/static/admin/js/jwt_session.js +150 -0
  149. stapel_core-0.3.2/testing.py +78 -0
  150. stapel_core-0.3.2/tests/test_bus.py +173 -0
  151. stapel_core-0.3.2/tests/test_bus_config.py +49 -0
  152. stapel_core-0.3.2/tests/test_bus_nats.py +144 -0
  153. stapel_core-0.3.2/tests/test_bus_routing.py +139 -0
  154. stapel_core-0.3.2/tests/test_captcha.py +139 -0
  155. stapel_core-0.3.2/tests/test_captcha_mixin.py +67 -0
  156. stapel_core-0.3.2/tests/test_captcha_policy.py +199 -0
  157. stapel_core-0.3.2/tests/test_captcha_protected.py +254 -0
  158. stapel_core-0.3.2/tests/test_comm.py +379 -0
  159. stapel_core-0.3.2/tests/test_comm_tasks.py +196 -0
  160. stapel_core-0.3.2/tests/test_comm_tasks_gaps.py +185 -0
  161. stapel_core-0.3.2/tests/test_conf_gaps.py +85 -0
  162. stapel_core-0.3.2/tests/test_core_hardening.py +155 -0
  163. stapel_core-0.3.2/tests/test_cov_api_admin_groups.py +324 -0
  164. stapel_core-0.3.2/tests/test_cov_api_errors_serializers.py +380 -0
  165. stapel_core-0.3.2/tests/test_cov_api_openapi.py +545 -0
  166. stapel_core-0.3.2/tests/test_cov_api_pagination.py +285 -0
  167. stapel_core-0.3.2/tests/test_cov_api_revision.py +343 -0
  168. stapel_core-0.3.2/tests/test_cov_infra_bus.py +373 -0
  169. stapel_core-0.3.2/tests/test_cov_infra_captcha.py +79 -0
  170. stapel_core-0.3.2/tests/test_cov_infra_cdn.py +330 -0
  171. stapel_core-0.3.2/tests/test_cov_infra_comm.py +480 -0
  172. stapel_core-0.3.2/tests/test_cov_infra_django_models.py +295 -0
  173. stapel_core-0.3.2/tests/test_cov_infra_monitoring.py +312 -0
  174. stapel_core-0.3.2/tests/test_cov_jwt_auth_provider.py +371 -0
  175. stapel_core-0.3.2/tests/test_cov_jwt_core.py +330 -0
  176. stapel_core-0.3.2/tests/test_cov_jwt_utils.py +520 -0
  177. stapel_core-0.3.2/tests/test_cov_jwt_views.py +307 -0
  178. stapel_core-0.3.2/tests/test_cov_mopup.py +87 -0
  179. stapel_core-0.3.2/tests/test_errors.py +363 -0
  180. stapel_core-0.3.2/tests/test_flows.py +112 -0
  181. stapel_core-0.3.2/tests/test_jwt.py +414 -0
  182. stapel_core-0.3.2/tests/test_middleware.py +425 -0
  183. stapel_core-0.3.2/tests/test_netintel.py +490 -0
  184. stapel_core-0.3.2/tests/test_notification_publish.py +89 -0
  185. stapel_core-0.3.2/tests/test_notification_tokens.py +54 -0
  186. stapel_core-0.3.2/tests/test_oauth.py +101 -0
  187. stapel_core-0.3.2/tests/test_outbox_relay_gaps.py +54 -0
  188. stapel_core-0.3.2/tests/test_permissions.py +204 -0
  189. stapel_core-0.3.2/tests/test_public_api.py +107 -0
  190. stapel_core-0.3.2/tests/test_schemas_autoload.py +71 -0
  191. stapel_core-0.3.2/tests/test_task_dispatch.py +154 -0
  192. stapel_core-0.3.2/tests/test_utils.py +346 -0
  193. stapel_core-0.3.2/tests/test_verification.py +414 -0
  194. stapel_core-0.3.2/verification/__init__.py +64 -0
  195. stapel_core-0.3.2/verification/conf.py +27 -0
  196. stapel_core-0.3.2/verification/decorators.py +154 -0
  197. stapel_core-0.3.2/verification/errors.py +24 -0
  198. stapel_core-0.3.2/verification/factors.py +102 -0
  199. stapel_core-0.3.2/verification/grants.py +166 -0
  200. stapel_core-0.3.2/verification/policy.py +102 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 usestapel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,294 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-core
3
+ Version: 0.3.2
4
+ Summary: Core Django utilities for the Stapel framework
5
+ License: MIT
6
+ Keywords: django,stapel,auth,jwt
7
+ Classifier: Framework :: Django
8
+ Classifier: Framework :: Django :: 5.2
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: Django>=5.1
15
+ Requires-Dist: djangorestframework>=3.14
16
+ Requires-Dist: djangorestframework-dataclasses>=1.2
17
+ Requires-Dist: drf-spectacular>=0.27
18
+ Requires-Dist: django-cors-headers>=4.3
19
+ Requires-Dist: django-redis>=5.4
20
+ Requires-Dist: PyJWT>=2.8
21
+ Requires-Dist: cryptography>=41.0
22
+ Requires-Dist: requests>=2.31
23
+ Requires-Dist: phonenumbers>=8.13
24
+ Provides-Extra: kafka
25
+ Requires-Dist: confluent-kafka>=2.3; extra == "kafka"
26
+ Provides-Extra: nats
27
+ Requires-Dist: nats-py>=2.9; extra == "nats"
28
+ Provides-Extra: sentry
29
+ Requires-Dist: sentry-sdk>=1.40; extra == "sentry"
30
+ Provides-Extra: netintel-maxmind
31
+ Requires-Dist: geoip2>=4.7; extra == "netintel-maxmind"
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=7.4; extra == "dev"
34
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
35
+ Requires-Dist: celery>=5.3; extra == "dev"
36
+ Provides-Extra: all
37
+ Requires-Dist: stapel-core[kafka,nats,netintel-maxmind,sentry]; extra == "all"
38
+ Dynamic: license-file
39
+
40
+ # stapel_core
41
+
42
+ [![CI](https://github.com/usestapel/stapel-core/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-core/actions/workflows/ci.yml)
43
+ [![codecov](https://codecov.io/gh/usestapel/stapel-core/graph/badge.svg)](https://codecov.io/gh/usestapel/stapel-core)
44
+
45
+ Shared Python library for Stapel services. Provides JWT authentication, captcha
46
+ verification, event bus, notifications, and Django utilities used across all
47
+ backend services.
48
+
49
+ Part of the [Stapel framework](https://github.com/usestapel).
50
+
51
+ ## Quick start for a new Django service
52
+
53
+ ```bash
54
+ pip install -e ../iron-common-python
55
+ ```
56
+
57
+ Add to `INSTALLED_APPS`:
58
+
59
+ ```python
60
+ INSTALLED_APPS = [
61
+ ...
62
+ 'stapel_core.django',
63
+ 'stapel_core.django.users', # if using the shared User model
64
+ ]
65
+ ```
66
+
67
+ ## Modules
68
+
69
+ ### `stapel_core.captcha` — Pluggable captcha verification
70
+
71
+ Backend-agnostic captcha interface. Supports Cloudflare Turnstile, Google
72
+ reCAPTCHA v2, hCaptcha, and custom backends.
73
+
74
+ **Settings** (per service, in `settings/base.py`):
75
+
76
+ ```python
77
+ CAPTCHA_BACKEND = env.str('CAPTCHA_BACKEND', 'turnstile')
78
+ CAPTCHA_SECRET = env.str('CAPTCHA_SECRET', None) # absent → disabled
79
+ ```
80
+
81
+ **Auto-disable**: if `CAPTCHA_SECRET` is `None` or empty, `build_verifier`
82
+ returns `NoopVerifier` regardless of backend. No separate toggle needed.
83
+
84
+ **DRF integration** (add mixin to any serializer):
85
+
86
+ ```python
87
+ from stapel_core.django.captcha import CaptchaMixin
88
+
89
+ class MySerializer(CaptchaMixin, serializers.Serializer):
90
+ captcha_token = serializers.CharField(required=False, allow_blank=True)
91
+
92
+ def validate(self, attrs):
93
+ self._require_captcha_if_configured(attrs)
94
+ return attrs
95
+ ```
96
+
97
+ **Custom backend** — subclass `CaptchaVerifier` and point to it via a dotted
98
+ import path:
99
+
100
+ ```python
101
+ from stapel_core.captcha import CaptchaVerifier
102
+
103
+ class MyCaptchaVerifier(CaptchaVerifier):
104
+ def verify(self, token: str, ip: str | None = None) -> bool:
105
+ return my_service.check(token, self.secret)
106
+ ```
107
+
108
+ ```python
109
+ # settings.py
110
+ CAPTCHA_BACKEND = 'myapp.captcha.MyCaptchaVerifier'
111
+ CAPTCHA_SECRET = 'my-secret'
112
+ ```
113
+
114
+ The flat settings above keep working; the namespaced equivalent is
115
+ `STAPEL_CAPTCHA = {"BACKEND": ..., "SECRET": ...}`.
116
+
117
+ **Tiered challenge policy** — instead of a binary on/off, protect a view with
118
+ a strictness level derived from the client's network class (via
119
+ `stapel_core.netintel`):
120
+
121
+ ```python
122
+ from stapel_core.django.captcha import captcha_protected
123
+
124
+ class RegisterView(APIView):
125
+ @captcha_protected(action="register")
126
+ def post(self, request): ...
127
+ ```
128
+
129
+ Levels: `none < invisible < interactive < interactive+ratelimit < block`.
130
+ The default matrix (overridable via `STAPEL_CAPTCHA["CHALLENGE_MATRIX"]`,
131
+ merged over the defaults) maps residential/unknown → invisible,
132
+ datacenter/vpn → interactive, tor → interactive+ratelimit. Per-action
133
+ overrides: `STAPEL_CAPTCHA["ACTION_OVERRIDES"] = {"register": "+1"}` (bump one
134
+ level) or `{"payout": {"vpn": "block"}}`. The whole policy is swappable via
135
+ `STAPEL_CAPTCHA["CHALLENGE_POLICY"]` (dotted path to a `ChallengePolicy`).
136
+ `block` returns 403 `error.403.network_blocked`; rate limiting is not done
137
+ here — middleware reads `request.stapel_challenge_level`. With no netintel
138
+ provider configured every request classifies as `unknown` → behavior is
139
+ identical to the classic binary captcha.
140
+
141
+ ---
142
+
143
+ ### `stapel_core.netintel` — IP intelligence (network class + geo)
144
+
145
+ `classify_ip(ip) -> IpProfile{kind, asn, asn_org, country, confidence}` and
146
+ `country_of(ip)`. Kind vocabulary: `residential | datacenter | vpn | tor |
147
+ unknown`. Results are cached in the Django cache; provider errors fail open
148
+ to `unknown` and never raise.
149
+
150
+ ```python
151
+ STAPEL_NETINTEL = {
152
+ # dotted path / class / instance of a NetIntelProvider (replace seam)
153
+ "PROVIDER": "stapel_core.netintel.providers.MaxMindProvider",
154
+ "MAXMIND_ASN_DB": "/var/geoip/GeoLite2-ASN.mmdb",
155
+ "MAXMIND_COUNTRY_DB": "/var/geoip/GeoLite2-Country.mmdb",
156
+ "MAXMIND_ANONYMOUS_DB": "/var/geoip/GeoIP2-Anonymous-IP.mmdb",
157
+ }
158
+ ```
159
+
160
+ Built-in providers: `NullProvider` (default — always `unknown`),
161
+ `MaxMindProvider` (offline mmdb, `pip install stapel-core[netintel-maxmind]`),
162
+ `HttpJsonProvider` (ipinfo/IPQS-style HTTP APIs via `HTTP_URL_TEMPLATE` /
163
+ `HTTP_API_KEY` / `HTTP_RESPONSE_MAPPER`). `client_ip(request)` honors
164
+ `TRUSTED_PROXY_HEADER` (default: `REMOTE_ADDR` only — proxy headers are
165
+ spoofable unless your edge overwrites them).
166
+
167
+ ---
168
+
169
+ ### `stapel_core.django.jwt` — JWT authentication
170
+
171
+ Unified JWT provider (singleton). Supports HS256 and RS256.
172
+
173
+ ```python
174
+ from stapel_core.django.jwt.provider import jwt_provider
175
+
176
+ access, refresh = jwt_provider.create_tokens(user)
177
+ payload = jwt_provider.validate_token(access_token)
178
+ ```
179
+
180
+ **Settings**:
181
+
182
+ ```python
183
+ JWT_ALGORITHM = 'HS256' # or 'RS256'
184
+ JWT_SECRET_KEY = 'your-secret' # HS256
185
+ JWT_PRIVATE_KEY = '...' # RS256
186
+ JWT_PUBLIC_KEY = '...' # RS256
187
+ JWT_ISSUER = 'https://yourapp.com'
188
+ JWT_AUDIENCE = None
189
+ JWT_ACCESS_TOKEN_LIFETIME = 900 # seconds
190
+ JWT_REFRESH_TOKEN_LIFETIME = 604800 # seconds
191
+ ```
192
+
193
+ ---
194
+
195
+ ### `stapel_core.django.authentication` — JWT cookie auth
196
+
197
+ `JWTCookieAuthentication` reads JWT from `access_token` cookie or
198
+ `Authorization: Bearer <token>` header.
199
+
200
+ ```python
201
+ REST_FRAMEWORK = {
202
+ 'DEFAULT_AUTHENTICATION_CLASSES': [
203
+ 'stapel_core.django.authentication.JWTCookieAuthentication',
204
+ ],
205
+ }
206
+ ```
207
+
208
+ ---
209
+
210
+ ### `stapel_core.django.api` — DRF utilities
211
+
212
+ | Symbol | Purpose |
213
+ |---|---|
214
+ | `StapelDataclassSerializer` | Serializer that maps `@dataclass` fields |
215
+ | `StapelResponse(serializer)` | Wraps `.data` automatically |
216
+ | `StapelErrorResponse(status, ERR_KEY)` | Structured error response |
217
+ | `StapelValidationError(ERR_KEY)` | Raises DRF validation error with error key |
218
+ | `register_service_errors(dict)` | Registers error messages for a service |
219
+ | `AnchorPagination` / `CreatedAtAnchorPagination` | Cursor-style paginators |
220
+
221
+ ---
222
+
223
+ ### `stapel_core.bus` — Event bus
224
+
225
+ Kafka-backed event bus with an in-memory backend for tests.
226
+
227
+ **Publish** (sync, fire-and-forget):
228
+
229
+ ```python
230
+ from stapel_core.bus import publish, Event
231
+
232
+ publish('user.created', Event(
233
+ event_type='user.created',
234
+ service='auth',
235
+ payload={'user_id': '...'},
236
+ ))
237
+ ```
238
+
239
+ **Consume** by subclassing the management-command base:
240
+
241
+ ```python
242
+ from stapel_core.bus import BaseBusConsumerCommand, Event
243
+
244
+ class ConsumeUsers(BaseBusConsumerCommand):
245
+ topics = ['user.created']
246
+ consumer_group = 'notifications'
247
+
248
+ def handle_event(self, event: Event) -> None:
249
+ ...
250
+ ```
251
+
252
+ Backend is selected via the `STAPEL_BUS_BACKEND` Django setting
253
+ (`stapel_core.bus.backends.kafka.KafkaBus` in production,
254
+ `stapel_core.bus.backends.memory.MemoryBus` in tests).
255
+
256
+ ---
257
+
258
+ ### `stapel_core.notifications` — Push notifications
259
+
260
+ ```python
261
+ from stapel_core.notifications import request_notification
262
+
263
+ request_notification(
264
+ notification_type='welcome',
265
+ user_id=str(user.id),
266
+ email=user.email,
267
+ variables={'name': user.username},
268
+ source_service='auth',
269
+ )
270
+ ```
271
+
272
+ ---
273
+
274
+ ### `stapel_core.oauth` — OAuth provider registry
275
+
276
+ Provider classes (`GoogleProvider`, `GitHubProvider`, etc.) and registry for
277
+ OAuth consumer flows (when your service accepts OAuth logins from external
278
+ providers).
279
+
280
+ ---
281
+
282
+ ### `stapel_core.gdpr` — GDPR utilities
283
+
284
+ Account closure requests, data export, re-registration hashes.
285
+
286
+ ---
287
+
288
+ ## Running tests
289
+
290
+ ```bash
291
+ cd iron-common-python
292
+ pip install -e '.[dev]'
293
+ pytest stapel_core/tests/ -v
294
+ ```
@@ -0,0 +1,255 @@
1
+ # stapel_core
2
+
3
+ [![CI](https://github.com/usestapel/stapel-core/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-core/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/usestapel/stapel-core/graph/badge.svg)](https://codecov.io/gh/usestapel/stapel-core)
5
+
6
+ Shared Python library for Stapel services. Provides JWT authentication, captcha
7
+ verification, event bus, notifications, and Django utilities used across all
8
+ backend services.
9
+
10
+ Part of the [Stapel framework](https://github.com/usestapel).
11
+
12
+ ## Quick start for a new Django service
13
+
14
+ ```bash
15
+ pip install -e ../iron-common-python
16
+ ```
17
+
18
+ Add to `INSTALLED_APPS`:
19
+
20
+ ```python
21
+ INSTALLED_APPS = [
22
+ ...
23
+ 'stapel_core.django',
24
+ 'stapel_core.django.users', # if using the shared User model
25
+ ]
26
+ ```
27
+
28
+ ## Modules
29
+
30
+ ### `stapel_core.captcha` — Pluggable captcha verification
31
+
32
+ Backend-agnostic captcha interface. Supports Cloudflare Turnstile, Google
33
+ reCAPTCHA v2, hCaptcha, and custom backends.
34
+
35
+ **Settings** (per service, in `settings/base.py`):
36
+
37
+ ```python
38
+ CAPTCHA_BACKEND = env.str('CAPTCHA_BACKEND', 'turnstile')
39
+ CAPTCHA_SECRET = env.str('CAPTCHA_SECRET', None) # absent → disabled
40
+ ```
41
+
42
+ **Auto-disable**: if `CAPTCHA_SECRET` is `None` or empty, `build_verifier`
43
+ returns `NoopVerifier` regardless of backend. No separate toggle needed.
44
+
45
+ **DRF integration** (add mixin to any serializer):
46
+
47
+ ```python
48
+ from stapel_core.django.captcha import CaptchaMixin
49
+
50
+ class MySerializer(CaptchaMixin, serializers.Serializer):
51
+ captcha_token = serializers.CharField(required=False, allow_blank=True)
52
+
53
+ def validate(self, attrs):
54
+ self._require_captcha_if_configured(attrs)
55
+ return attrs
56
+ ```
57
+
58
+ **Custom backend** — subclass `CaptchaVerifier` and point to it via a dotted
59
+ import path:
60
+
61
+ ```python
62
+ from stapel_core.captcha import CaptchaVerifier
63
+
64
+ class MyCaptchaVerifier(CaptchaVerifier):
65
+ def verify(self, token: str, ip: str | None = None) -> bool:
66
+ return my_service.check(token, self.secret)
67
+ ```
68
+
69
+ ```python
70
+ # settings.py
71
+ CAPTCHA_BACKEND = 'myapp.captcha.MyCaptchaVerifier'
72
+ CAPTCHA_SECRET = 'my-secret'
73
+ ```
74
+
75
+ The flat settings above keep working; the namespaced equivalent is
76
+ `STAPEL_CAPTCHA = {"BACKEND": ..., "SECRET": ...}`.
77
+
78
+ **Tiered challenge policy** — instead of a binary on/off, protect a view with
79
+ a strictness level derived from the client's network class (via
80
+ `stapel_core.netintel`):
81
+
82
+ ```python
83
+ from stapel_core.django.captcha import captcha_protected
84
+
85
+ class RegisterView(APIView):
86
+ @captcha_protected(action="register")
87
+ def post(self, request): ...
88
+ ```
89
+
90
+ Levels: `none < invisible < interactive < interactive+ratelimit < block`.
91
+ The default matrix (overridable via `STAPEL_CAPTCHA["CHALLENGE_MATRIX"]`,
92
+ merged over the defaults) maps residential/unknown → invisible,
93
+ datacenter/vpn → interactive, tor → interactive+ratelimit. Per-action
94
+ overrides: `STAPEL_CAPTCHA["ACTION_OVERRIDES"] = {"register": "+1"}` (bump one
95
+ level) or `{"payout": {"vpn": "block"}}`. The whole policy is swappable via
96
+ `STAPEL_CAPTCHA["CHALLENGE_POLICY"]` (dotted path to a `ChallengePolicy`).
97
+ `block` returns 403 `error.403.network_blocked`; rate limiting is not done
98
+ here — middleware reads `request.stapel_challenge_level`. With no netintel
99
+ provider configured every request classifies as `unknown` → behavior is
100
+ identical to the classic binary captcha.
101
+
102
+ ---
103
+
104
+ ### `stapel_core.netintel` — IP intelligence (network class + geo)
105
+
106
+ `classify_ip(ip) -> IpProfile{kind, asn, asn_org, country, confidence}` and
107
+ `country_of(ip)`. Kind vocabulary: `residential | datacenter | vpn | tor |
108
+ unknown`. Results are cached in the Django cache; provider errors fail open
109
+ to `unknown` and never raise.
110
+
111
+ ```python
112
+ STAPEL_NETINTEL = {
113
+ # dotted path / class / instance of a NetIntelProvider (replace seam)
114
+ "PROVIDER": "stapel_core.netintel.providers.MaxMindProvider",
115
+ "MAXMIND_ASN_DB": "/var/geoip/GeoLite2-ASN.mmdb",
116
+ "MAXMIND_COUNTRY_DB": "/var/geoip/GeoLite2-Country.mmdb",
117
+ "MAXMIND_ANONYMOUS_DB": "/var/geoip/GeoIP2-Anonymous-IP.mmdb",
118
+ }
119
+ ```
120
+
121
+ Built-in providers: `NullProvider` (default — always `unknown`),
122
+ `MaxMindProvider` (offline mmdb, `pip install stapel-core[netintel-maxmind]`),
123
+ `HttpJsonProvider` (ipinfo/IPQS-style HTTP APIs via `HTTP_URL_TEMPLATE` /
124
+ `HTTP_API_KEY` / `HTTP_RESPONSE_MAPPER`). `client_ip(request)` honors
125
+ `TRUSTED_PROXY_HEADER` (default: `REMOTE_ADDR` only — proxy headers are
126
+ spoofable unless your edge overwrites them).
127
+
128
+ ---
129
+
130
+ ### `stapel_core.django.jwt` — JWT authentication
131
+
132
+ Unified JWT provider (singleton). Supports HS256 and RS256.
133
+
134
+ ```python
135
+ from stapel_core.django.jwt.provider import jwt_provider
136
+
137
+ access, refresh = jwt_provider.create_tokens(user)
138
+ payload = jwt_provider.validate_token(access_token)
139
+ ```
140
+
141
+ **Settings**:
142
+
143
+ ```python
144
+ JWT_ALGORITHM = 'HS256' # or 'RS256'
145
+ JWT_SECRET_KEY = 'your-secret' # HS256
146
+ JWT_PRIVATE_KEY = '...' # RS256
147
+ JWT_PUBLIC_KEY = '...' # RS256
148
+ JWT_ISSUER = 'https://yourapp.com'
149
+ JWT_AUDIENCE = None
150
+ JWT_ACCESS_TOKEN_LIFETIME = 900 # seconds
151
+ JWT_REFRESH_TOKEN_LIFETIME = 604800 # seconds
152
+ ```
153
+
154
+ ---
155
+
156
+ ### `stapel_core.django.authentication` — JWT cookie auth
157
+
158
+ `JWTCookieAuthentication` reads JWT from `access_token` cookie or
159
+ `Authorization: Bearer <token>` header.
160
+
161
+ ```python
162
+ REST_FRAMEWORK = {
163
+ 'DEFAULT_AUTHENTICATION_CLASSES': [
164
+ 'stapel_core.django.authentication.JWTCookieAuthentication',
165
+ ],
166
+ }
167
+ ```
168
+
169
+ ---
170
+
171
+ ### `stapel_core.django.api` — DRF utilities
172
+
173
+ | Symbol | Purpose |
174
+ |---|---|
175
+ | `StapelDataclassSerializer` | Serializer that maps `@dataclass` fields |
176
+ | `StapelResponse(serializer)` | Wraps `.data` automatically |
177
+ | `StapelErrorResponse(status, ERR_KEY)` | Structured error response |
178
+ | `StapelValidationError(ERR_KEY)` | Raises DRF validation error with error key |
179
+ | `register_service_errors(dict)` | Registers error messages for a service |
180
+ | `AnchorPagination` / `CreatedAtAnchorPagination` | Cursor-style paginators |
181
+
182
+ ---
183
+
184
+ ### `stapel_core.bus` — Event bus
185
+
186
+ Kafka-backed event bus with an in-memory backend for tests.
187
+
188
+ **Publish** (sync, fire-and-forget):
189
+
190
+ ```python
191
+ from stapel_core.bus import publish, Event
192
+
193
+ publish('user.created', Event(
194
+ event_type='user.created',
195
+ service='auth',
196
+ payload={'user_id': '...'},
197
+ ))
198
+ ```
199
+
200
+ **Consume** by subclassing the management-command base:
201
+
202
+ ```python
203
+ from stapel_core.bus import BaseBusConsumerCommand, Event
204
+
205
+ class ConsumeUsers(BaseBusConsumerCommand):
206
+ topics = ['user.created']
207
+ consumer_group = 'notifications'
208
+
209
+ def handle_event(self, event: Event) -> None:
210
+ ...
211
+ ```
212
+
213
+ Backend is selected via the `STAPEL_BUS_BACKEND` Django setting
214
+ (`stapel_core.bus.backends.kafka.KafkaBus` in production,
215
+ `stapel_core.bus.backends.memory.MemoryBus` in tests).
216
+
217
+ ---
218
+
219
+ ### `stapel_core.notifications` — Push notifications
220
+
221
+ ```python
222
+ from stapel_core.notifications import request_notification
223
+
224
+ request_notification(
225
+ notification_type='welcome',
226
+ user_id=str(user.id),
227
+ email=user.email,
228
+ variables={'name': user.username},
229
+ source_service='auth',
230
+ )
231
+ ```
232
+
233
+ ---
234
+
235
+ ### `stapel_core.oauth` — OAuth provider registry
236
+
237
+ Provider classes (`GoogleProvider`, `GitHubProvider`, etc.) and registry for
238
+ OAuth consumer flows (when your service accepts OAuth logins from external
239
+ providers).
240
+
241
+ ---
242
+
243
+ ### `stapel_core.gdpr` — GDPR utilities
244
+
245
+ Account closure requests, data export, re-registration hashes.
246
+
247
+ ---
248
+
249
+ ## Running tests
250
+
251
+ ```bash
252
+ cd iron-common-python
253
+ pip install -e '.[dev]'
254
+ pytest stapel_core/tests/ -v
255
+ ```
@@ -0,0 +1,101 @@
1
+ """Stapel Core — shared Django utilities for the Stapel framework.
2
+
3
+ The building blocks every Stapel package sits on:
4
+
5
+ - ``comm`` — Action/Function inter-module communication (``emit``,
6
+ ``on_action``, ``call``, ``function``) plus long-running tasks
7
+ (``start``, ``status``, ``task_handler``). Transports are deployment
8
+ configuration, not code.
9
+ - ``bus`` — transport-agnostic message bus (``publish``, ``get_bus``,
10
+ ``Event``) with Kafka/NATS/in-memory backends.
11
+ - ``conf.AppSettings`` — per-app settings namespaces (the DRF
12
+ ``api_settings`` pattern, generalized).
13
+ - ``signals`` — in-process Django signals for business milestones.
14
+ - ``django.api`` — API conventions: ``StapelResponse``,
15
+ ``StapelErrorResponse`` and ``StapelDataclassSerializer``.
16
+ - ``gdpr`` — GDPR provider protocol and in-process registry.
17
+ - ``netintel`` — IP intelligence seam (``classify_ip``, ``country_of``,
18
+ ``IpProfile``): pluggable provider, cached, fail-open.
19
+ - ``django.users.AbstractStapelUser`` — base user model.
20
+ - ``core`` — framework-agnostic JWT primitives (``JWTHandler``,
21
+ ``TokenManager``, ``TokenBlacklist``, ``JWTConfig``).
22
+
23
+ All attributes are exported lazily (PEP 562), so importing
24
+ ``stapel_core`` stays cheap and never touches Django until a
25
+ Django-dependent attribute is actually used.
26
+ """
27
+
28
+ from importlib import import_module
29
+
30
+ try: # single source of truth: pyproject's version via package metadata
31
+ from importlib.metadata import version as _pkg_version
32
+
33
+ __version__ = _pkg_version("stapel-core")
34
+ except Exception: # editable/vendored checkout without dist-info
35
+ __version__ = "0.3.0"
36
+
37
+ # Attribute name -> (relative module, attribute in that module).
38
+ # An attribute of None means the module itself is the export.
39
+ _LAZY_EXPORTS = {
40
+ # comm — Actions, Functions, long-running tasks
41
+ "emit": (".comm", "emit"),
42
+ "on_action": (".comm", "on_action"),
43
+ "call": (".comm", "call"),
44
+ "function": (".comm", "function"),
45
+ "start": (".comm", "start"),
46
+ "status": (".comm", "status"),
47
+ "task_handler": (".comm", "task_handler"),
48
+ # flows — self-documenting business scenarios
49
+ "Flow": (".flows", "Flow"),
50
+ "flow_step": (".flows", "flow_step"),
51
+ "flow_registry": (".flows", "flow_registry"),
52
+ # netintel — IP classification seam (provider dotted-path, cached, fail-open)
53
+ "classify_ip": (".netintel", "classify_ip"),
54
+ "country_of": (".netintel", "country_of"),
55
+ "IpProfile": (".netintel", "IpProfile"),
56
+ # verification — step-up (OTP/TOTP/passkey) on any endpoint
57
+ "requires_verification": (".verification", "requires_verification"),
58
+ "register_factor": (".verification", "register_factor"),
59
+ "VerificationFactor": (".verification", "VerificationFactor"),
60
+ "get_user_policy": (".verification", "get_user_policy"),
61
+ "invalidate_policy_cache": (".verification", "invalidate_policy_cache"),
62
+ # bus — transport-agnostic message bus
63
+ "publish": (".bus", "publish"),
64
+ "get_bus": (".bus", "get_bus"),
65
+ "Event": (".bus", "Event"),
66
+ # conf — per-app settings namespaces
67
+ "AppSettings": (".conf", "AppSettings"),
68
+ # signals — in-process business milestones (module export)
69
+ "signals": (".signals", None),
70
+ # API conventions — responses, errors, serializers
71
+ "StapelResponse": (".django.api.errors", "StapelResponse"),
72
+ "StapelErrorResponse": (".django.api.errors", "StapelErrorResponse"),
73
+ "StapelDataclassSerializer": (".django.api.serializers", "StapelDataclassSerializer"),
74
+ # GDPR — provider protocol + in-process registry
75
+ "GDPRProvider": (".gdpr", "GDPRProvider"),
76
+ "gdpr_registry": (".gdpr", "gdpr_registry"),
77
+ # Users — base user model
78
+ "AbstractStapelUser": (".django.users.models", "AbstractStapelUser"),
79
+ # Framework-agnostic JWT primitives (0.1.x root exports, kept stable)
80
+ "JWTHandler": (".core.jwt_handler", "JWTHandler"),
81
+ "TokenManager": (".core.token_manager", "TokenManager"),
82
+ "TokenBlacklist": (".core.token_blacklist", "TokenBlacklist"),
83
+ "JWTConfig": (".core.config", "JWTConfig"),
84
+ }
85
+
86
+ __all__ = sorted([*_LAZY_EXPORTS, "__version__"])
87
+
88
+
89
+ def __getattr__(name):
90
+ try:
91
+ module_path, attr = _LAZY_EXPORTS[name]
92
+ except KeyError:
93
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
94
+ module = import_module(module_path, __name__)
95
+ value = module if attr is None else getattr(module, attr)
96
+ globals()[name] = value # cache so __getattr__ runs once per name
97
+ return value
98
+
99
+
100
+ def __dir__():
101
+ return sorted(set(globals()) | set(_LAZY_EXPORTS))