swiftpy-framework 0.1.0__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.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
swiftpy/admin/auth.py ADDED
@@ -0,0 +1,522 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import logging
9
+ import secrets
10
+ import time
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from swiftpy.core.config import Config, ConfigurationError
15
+ from swiftpy.core.container import get_container
16
+ from swiftpy.core.context import Context
17
+ from swiftpy.http.request import Request
18
+ from swiftpy.http.response import Response
19
+
20
+ logger = logging.getLogger("swiftpy.admin.auth")
21
+
22
+ ADMIN_COOKIE_NAME = "swiftpy_admin_session"
23
+ DEFAULT_SESSION_MAX_AGE = 86400 # 24 hours
24
+ DEFAULT_MAX_REVOKED_SESSIONS = 10_000
25
+ _CONTEXT_ADMIN_KEY = "__swiftpy_admin_user__"
26
+ _LOCAL_DEV_ENVS = frozenset({"local", "development", "dev", "testing", "test"})
27
+
28
+
29
+ def _resolve_admin_config(explicit: Config | None = None) -> Config | None:
30
+ """Resolve the active application Config, falling back cleanly to global Config."""
31
+ if explicit is not None:
32
+ return explicit
33
+ try:
34
+ container = get_container()
35
+ if container.has(Config):
36
+ return container.resolve(Config)
37
+ except RuntimeError:
38
+ pass
39
+ try:
40
+ return Config.get_instance()
41
+ except (RuntimeError, ValueError):
42
+ return None
43
+
44
+
45
+ def _is_dev_debug_mode(cfg: Config | None) -> bool:
46
+ """Return True only when config explicitly declares a local/dev/test env in debug mode."""
47
+ if cfg is None:
48
+ return False
49
+ raw_env = cfg.get("app.env") or cfg.get("APP_ENV") or cfg.get("env")
50
+ if raw_env is None:
51
+ return False
52
+ app_env = str(raw_env).strip().lower()
53
+ if app_env not in _LOCAL_DEV_ENVS:
54
+ return False
55
+ raw_debug = cfg.get("app.debug")
56
+ if isinstance(raw_debug, bool):
57
+ return raw_debug
58
+ if isinstance(raw_debug, str):
59
+ return raw_debug.strip().lower() in ("true", "1", "yes", "on")
60
+ return bool(raw_debug) if raw_debug is not None else False
61
+
62
+
63
+ def _normalize_cookie_path(prefix: str) -> str:
64
+ """Normalize an admin mount prefix into a clean cookie Path attribute."""
65
+ stripped = prefix.strip("/")
66
+ return f"/{stripped}" if stripped else "/"
67
+
68
+
69
+ def _should_use_secure_cookie(
70
+ *,
71
+ request: Request | None = None,
72
+ secure: bool | None = None,
73
+ config: Config | None = None,
74
+ ) -> bool:
75
+ """Determine whether the Secure cookie flag should be set."""
76
+ if secure is not None:
77
+ return bool(secure)
78
+ cfg = _resolve_admin_config(config)
79
+ if cfg is not None:
80
+ explicit = cfg.get("admin.cookie_secure")
81
+ if isinstance(explicit, bool):
82
+ return explicit
83
+ if isinstance(explicit, str):
84
+ return explicit.strip().lower() in ("true", "1", "yes", "on")
85
+ if request is not None:
86
+ scheme = str(getattr(request, "scheme", "") or "").lower()
87
+ fwd_proto = str(request.headers.get("x-forwarded-proto") or "").split(",")[0].strip().lower()
88
+ if scheme == "https" or fwd_proto == "https":
89
+ return True
90
+ if cfg is not None:
91
+ raw_env = str(cfg.get("app.env") or cfg.get("env") or "").strip().lower()
92
+ app_url = str(cfg.get("app.url") or "").strip().lower()
93
+ if raw_env in ("production", "prod") or app_url.startswith("https://"):
94
+ return True
95
+ return False
96
+
97
+
98
+ def _b64encode(data: bytes) -> str:
99
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
100
+
101
+
102
+ def _b64decode(s: str) -> bytes:
103
+ padding = 4 - (len(s) % 4)
104
+ if padding != 4:
105
+ s += "=" * padding
106
+ return base64.urlsafe_b64decode(s.encode("ascii"))
107
+
108
+
109
+ def parse_cookie_header(cookie_header: str | None) -> dict[str, str]:
110
+ """Parse HTTP Cookie header string into a key-value dictionary."""
111
+ if not cookie_header:
112
+ return {}
113
+ cookies: dict[str, str] = {}
114
+ for part in cookie_header.split(";"):
115
+ part = part.strip()
116
+ if "=" in part:
117
+ k, v = part.split("=", 1)
118
+ cookies[k.strip()] = v.strip()
119
+ return cookies
120
+
121
+
122
+ class AdminUnauthorizedError(Exception):
123
+ """Raised when an unauthenticated request attempts to access protected admin resources."""
124
+
125
+ def __init__(self, message: str = "Admin authentication required.") -> None:
126
+ super().__init__(message)
127
+ self.message = message
128
+
129
+
130
+ class AdminUser:
131
+ """Represents an authenticated admin user session."""
132
+
133
+ def __init__(
134
+ self,
135
+ username: str,
136
+ is_admin: bool = True,
137
+ permissions: list[str] | None = None,
138
+ session_id: str | None = None,
139
+ metadata: dict[str, Any] | None = None,
140
+ ) -> None:
141
+ self.username = username
142
+ self.is_admin = is_admin
143
+ self.permissions = ["all"] if permissions is None else list(permissions)
144
+ self.session_id = session_id if session_id is not None else secrets.token_hex(16)
145
+ self.metadata = dict(metadata or {})
146
+
147
+ @property
148
+ def is_authenticated(self) -> bool:
149
+ return self.is_admin is True
150
+
151
+ def has_permission(self, permission: str | None = None) -> bool:
152
+ """Check whether this authenticated admin user holds the requested permission."""
153
+ if self.is_admin is not True:
154
+ return False
155
+ if "all" in self.permissions or "*" in self.permissions:
156
+ return True
157
+ if permission is None:
158
+ return False
159
+ return permission in self.permissions
160
+
161
+ def to_dict(self) -> dict[str, Any]:
162
+ return {
163
+ "username": self.username,
164
+ "is_admin": self.is_admin,
165
+ "permissions": self.permissions,
166
+ "session_id": self.session_id,
167
+ "metadata": self.metadata,
168
+ }
169
+
170
+ @classmethod
171
+ def from_dict(cls, data: dict[str, Any]) -> AdminUser:
172
+ raw_perms = data.get("permissions")
173
+ perms = [str(p) for p in raw_perms] if isinstance(raw_perms, list) else []
174
+ raw_meta = data.get("metadata")
175
+ meta = dict(raw_meta) if isinstance(raw_meta, dict) else {}
176
+ raw_sid = data.get("session_id")
177
+ sid = raw_sid if isinstance(raw_sid, str) else ""
178
+ return cls(
179
+ username=str(data.get("username", "")),
180
+ is_admin=data.get("is_admin") is True,
181
+ permissions=perms,
182
+ session_id=sid,
183
+ metadata=meta,
184
+ )
185
+
186
+ def __repr__(self) -> str:
187
+ return f"<AdminUser username={self.username!r} is_admin={self.is_admin}>"
188
+
189
+
190
+ class AdminSessionStore:
191
+ """
192
+ Session storage and token issuance manager for admin authentication.
193
+ Signs sessions using HMAC-SHA256 to ensure tamper-proof, stateless verification
194
+ with bounded TTL-pruned in-memory revocation tracking.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ secret_key: str | None = None,
200
+ config: Config | None = None,
201
+ max_revoked_sessions: int = DEFAULT_MAX_REVOKED_SESSIONS,
202
+ ) -> None:
203
+ self._config = config
204
+ self._secret_key = secret_key
205
+ self._ephemeral_secret: str | None = None
206
+ self._max_revoked_sessions = max(1, max_revoked_sessions)
207
+ self._revoked_sessions: dict[str, float] = {}
208
+
209
+ def get_secret_key(self) -> str:
210
+ if self._secret_key and self._secret_key.strip():
211
+ return self._secret_key
212
+ cfg = _resolve_admin_config(self._config)
213
+ key = cfg.get("admin.secret_key") or cfg.get("app.key") if cfg is not None else None
214
+ if key and str(key).strip():
215
+ return str(key)
216
+ if _is_dev_debug_mode(cfg):
217
+ if self._ephemeral_secret is None:
218
+ logger.warning(
219
+ "Admin session secret ('admin.secret_key' or 'app.key') is not configured; "
220
+ "generating an ephemeral key for local debug mode."
221
+ )
222
+ self._ephemeral_secret = secrets.token_hex(32)
223
+ return self._ephemeral_secret
224
+ raise ConfigurationError(
225
+ "Admin session secret key ('admin.secret_key' or 'app.key') is required "
226
+ "outside local debug mode."
227
+ )
228
+
229
+ def _prune_revoked_sessions(self, now: float | None = None) -> None:
230
+ ts = time.time() if now is None else now
231
+ expired = [sid for sid, exp_ts in self._revoked_sessions.items() if exp_ts <= ts]
232
+ for sid in expired:
233
+ self._revoked_sessions.pop(sid, None)
234
+ while len(self._revoked_sessions) > self._max_revoked_sessions:
235
+ oldest_sid = next(iter(self._revoked_sessions))
236
+ self._revoked_sessions.pop(oldest_sid, None)
237
+
238
+ def create_token(self, user: AdminUser, max_age: int = DEFAULT_SESSION_MAX_AGE) -> str:
239
+ """Create a signed, base64-encoded session token."""
240
+ now = int(time.time())
241
+ payload = {
242
+ "user": user.to_dict(),
243
+ "exp": now + max_age,
244
+ "iat": now,
245
+ }
246
+ payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
247
+ payload_b64 = _b64encode(payload_bytes)
248
+
249
+ key = self.get_secret_key().encode("utf-8")
250
+ sig = hmac.new(key, payload_b64.encode("ascii"), hashlib.sha256).digest()
251
+ sig_b64 = _b64encode(sig)
252
+
253
+ return f"{payload_b64}.{sig_b64}"
254
+
255
+ def verify_token(self, token: str) -> AdminUser | None:
256
+ """Verify signature, expiration, admin status, and session ID of a token."""
257
+ if not token or "." not in token:
258
+ return None
259
+ parts = token.split(".", 1)
260
+ if len(parts) != 2:
261
+ return None
262
+ payload_b64, sig_b64 = parts
263
+
264
+ key = self.get_secret_key().encode("utf-8")
265
+ expected_sig = hmac.new(key, payload_b64.encode("ascii"), hashlib.sha256).digest()
266
+ try:
267
+ provided_sig = _b64decode(sig_b64)
268
+ except (binascii.Error, ValueError):
269
+ return None
270
+
271
+ if not hmac.compare_digest(expected_sig, provided_sig):
272
+ return None
273
+
274
+ try:
275
+ payload_bytes = _b64decode(payload_b64)
276
+ data = json.loads(payload_bytes.decode("utf-8"))
277
+ except (binascii.Error, UnicodeDecodeError, json.JSONDecodeError, ValueError):
278
+ return None
279
+
280
+ if not isinstance(data, dict):
281
+ return None
282
+
283
+ # Check expiration
284
+ exp = data.get("exp")
285
+ if not isinstance(exp, (int, float)) or time.time() > exp:
286
+ return None
287
+
288
+ user_data = data.get("user")
289
+ if not isinstance(user_data, dict):
290
+ return None
291
+
292
+ raw_username = user_data.get("username")
293
+ raw_sid = user_data.get("session_id")
294
+ if not isinstance(raw_username, str) or not raw_username.strip():
295
+ return None
296
+ if not isinstance(raw_sid, str) or not raw_sid.strip():
297
+ return None
298
+ if user_data.get("is_admin") is not True:
299
+ return None
300
+
301
+ user = AdminUser.from_dict(user_data)
302
+ self._prune_revoked_sessions()
303
+ if user.session_id in self._revoked_sessions:
304
+ return None
305
+
306
+ return user
307
+
308
+ def revoke_session(self, session_id: str, ttl: int | None = None) -> None:
309
+ """Revoke an active admin session ID with TTL pruning and bounded retention."""
310
+ if not session_id:
311
+ return
312
+ now = time.time()
313
+ ttl_sec = DEFAULT_SESSION_MAX_AGE if ttl is None else ttl
314
+ self._revoked_sessions[session_id] = now + ttl_sec
315
+ self._prune_revoked_sessions(now)
316
+
317
+ def generate_csrf_token(self, user: AdminUser) -> str:
318
+ """Generate a cryptographically signed CSRF token bound to the admin session."""
319
+ salt = secrets.token_hex(8)
320
+ msg = f"{user.session_id}:{salt}".encode()
321
+ key = self.get_secret_key().encode()
322
+ sig = hmac.new(key, msg, hashlib.sha256).hexdigest()
323
+ return f"{salt}:{sig}"
324
+
325
+ def verify_csrf_token(self, token: str | None, user: AdminUser) -> bool:
326
+ """Verify that a CSRF token is authentic and bound to the current admin session."""
327
+ if not token or ":" not in token:
328
+ return False
329
+ parts = token.split(":", 1)
330
+ if len(parts) != 2:
331
+ return False
332
+ salt, provided_sig = parts
333
+ msg = f"{user.session_id}:{salt}".encode()
334
+ key = self.get_secret_key().encode()
335
+ expected_sig = hmac.new(key, msg, hashlib.sha256).hexdigest()
336
+ return hmac.compare_digest(expected_sig, provided_sig)
337
+
338
+
339
+ class AdminAuthenticator:
340
+ """Validates admin credentials with pluggable custom authenticators."""
341
+
342
+ def __init__(self, config: Config | None = None) -> None:
343
+ self._config = config
344
+ self._custom_auth: Callable[[str, str], bool | AdminUser | None] | None = None
345
+
346
+ def set_authenticator(self, auth_func: Callable[[str, str], bool | AdminUser | None]) -> None:
347
+ """Register a custom authentication callback: func(username, password) -> bool | AdminUser | None."""
348
+ self._custom_auth = auth_func
349
+
350
+ def authenticate(self, username: str, password: str) -> AdminUser | None:
351
+ """Verify credentials against custom authenticator or configured defaults."""
352
+ if self._custom_auth is not None:
353
+ res = self._custom_auth(username, password)
354
+ if isinstance(res, AdminUser):
355
+ return res if res.is_admin is True else None
356
+ if res is True:
357
+ return AdminUser(username=username)
358
+ return None
359
+
360
+ # Resolve configured admin credentials
361
+ cfg = _resolve_admin_config(self._config)
362
+ raw_user = cfg.get("admin.username") if cfg is not None else None
363
+ raw_pass = cfg.get("admin.password") if cfg is not None else None
364
+
365
+ has_custom_user = bool(raw_user and str(raw_user).strip())
366
+ has_custom_pass = bool(raw_pass and str(raw_pass))
367
+ is_default_pair = (
368
+ (not has_custom_user or str(raw_user) == "admin")
369
+ and (not has_custom_pass or str(raw_pass) == "admin123")
370
+ )
371
+
372
+ if (
373
+ (not has_custom_user or not has_custom_pass or is_default_pair)
374
+ and not _is_dev_debug_mode(cfg)
375
+ ):
376
+ raise ConfigurationError(
377
+ "Default administrative credentials ('admin'/'admin123') are strictly prohibited in production mode "
378
+ "and non-debug environments. Set ADMIN_USERNAME and ADMIN_PASSWORD or configure a custom admin authenticator."
379
+ )
380
+
381
+ expected_user = str(raw_user) if has_custom_user else "admin"
382
+ expected_pass = str(raw_pass) if has_custom_pass else "admin123"
383
+
384
+ # Constant-time comparison
385
+ user_ok = hmac.compare_digest(username.strip(), expected_user.strip())
386
+ pass_ok = hmac.compare_digest(password, expected_pass)
387
+
388
+ if user_ok and pass_ok:
389
+ return AdminUser(username=username)
390
+ return None
391
+
392
+
393
+ # Global singleton instances
394
+ _admin_session_store = AdminSessionStore()
395
+ _admin_authenticator = AdminAuthenticator()
396
+
397
+
398
+ def get_admin_session_store() -> AdminSessionStore:
399
+ return _admin_session_store
400
+
401
+
402
+ def get_admin_authenticator() -> AdminAuthenticator:
403
+ return _admin_authenticator
404
+
405
+
406
+ def create_admin_session(
407
+ response: Response,
408
+ user: AdminUser,
409
+ max_age: int = DEFAULT_SESSION_MAX_AGE,
410
+ *,
411
+ prefix: str = "/admin",
412
+ request: Request | None = None,
413
+ secure: bool | None = None,
414
+ ) -> str:
415
+ """Create a signed admin session token and attach it as a Set-Cookie header."""
416
+ store = get_admin_session_store()
417
+ token = store.create_token(user, max_age=max_age)
418
+ cookie_path = _normalize_cookie_path(prefix)
419
+ cookie_parts = [
420
+ f"{ADMIN_COOKIE_NAME}={token}",
421
+ f"Path={cookie_path}",
422
+ f"Max-Age={max_age}",
423
+ "HttpOnly",
424
+ "SameSite=Lax",
425
+ ]
426
+ if _should_use_secure_cookie(request=request, secure=secure):
427
+ cookie_parts.append("Secure")
428
+ response.set_cookie("; ".join(cookie_parts))
429
+ return token
430
+
431
+
432
+ def clear_admin_session(
433
+ response: Response,
434
+ request: Request | None = None,
435
+ *,
436
+ prefix: str = "/admin",
437
+ secure: bool | None = None,
438
+ ) -> None:
439
+ """Clear the admin session cookie and revoke the session in store."""
440
+ if request is not None:
441
+ user = get_current_admin(request)
442
+ if user is not None:
443
+ store = get_admin_session_store()
444
+ store.revoke_session(user.session_id)
445
+ Context.set(_CONTEXT_ADMIN_KEY, None)
446
+ if hasattr(request, "state"):
447
+ request.state.pop("admin_user", None)
448
+
449
+ cookie_path = _normalize_cookie_path(prefix)
450
+ cookie_parts = [
451
+ f"{ADMIN_COOKIE_NAME}=",
452
+ f"Path={cookie_path}",
453
+ "Max-Age=0",
454
+ "Expires=Thu, 01 Jan 1970 00:00:00 GMT",
455
+ "HttpOnly",
456
+ "SameSite=Lax",
457
+ ]
458
+ if _should_use_secure_cookie(request=request, secure=secure):
459
+ cookie_parts.append("Secure")
460
+ response.set_cookie("; ".join(cookie_parts))
461
+
462
+
463
+ def get_current_admin(request: Request) -> AdminUser | None:
464
+ """
465
+ Extract and verify the admin session from request cookies.
466
+ Attaches authenticated AdminUser to request.state and task Context.
467
+ """
468
+ # Check if already cached on this request
469
+ cached = getattr(request, "state", {}).get("admin_user")
470
+ if isinstance(cached, AdminUser):
471
+ return cached if cached.is_admin is True else None
472
+
473
+ cookie_header = request.headers.get("cookie")
474
+ cookies = parse_cookie_header(cookie_header)
475
+ token = cookies.get(ADMIN_COOKIE_NAME)
476
+ if not token:
477
+ return None
478
+
479
+ store = get_admin_session_store()
480
+ admin_user = store.verify_token(token)
481
+ if admin_user is not None and admin_user.is_admin is True:
482
+ request.state["admin_user"] = admin_user
483
+ Context.set(_CONTEXT_ADMIN_KEY, admin_user.to_dict())
484
+ return admin_user
485
+ return None
486
+
487
+
488
+ def require_admin(request: Request) -> AdminUser:
489
+ """
490
+ Enforce authenticated admin session.
491
+ Returns the authenticated AdminUser or raises AdminUnauthorizedError.
492
+ """
493
+ user = get_current_admin(request)
494
+ if user is None or user.is_admin is not True:
495
+ raise AdminUnauthorizedError("Admin session required. Please sign in.")
496
+ return user
497
+
498
+
499
+ def current_admin() -> AdminUser | None:
500
+ """Retrieve the current admin user from task-scoped Context."""
501
+ raw = Context.get(_CONTEXT_ADMIN_KEY)
502
+ if isinstance(raw, dict):
503
+ user = AdminUser.from_dict(raw)
504
+ return user if user.is_admin is True else None
505
+ return None
506
+
507
+
508
+ __all__ = [
509
+ "ADMIN_COOKIE_NAME",
510
+ "AdminAuthenticator",
511
+ "AdminSessionStore",
512
+ "AdminUnauthorizedError",
513
+ "AdminUser",
514
+ "clear_admin_session",
515
+ "create_admin_session",
516
+ "current_admin",
517
+ "get_admin_authenticator",
518
+ "get_admin_session_store",
519
+ "get_current_admin",
520
+ "parse_cookie_header",
521
+ "require_admin",
522
+ ]