svc-infra 0.1.589__py3-none-any.whl → 0.1.706__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.

Potentially problematic release.


This version of svc-infra might be problematic. Click here for more details.

Files changed (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Callable, Iterable, Optional
6
+
7
+ COMMON_PASSWORDS = {"password", "123456", "qwerty", "letmein", "admin"}
8
+
9
+ HIBP_DISABLED = False # default enabled; can be toggled via settings at startup
10
+
11
+
12
+ @dataclass
13
+ class PasswordPolicy:
14
+ min_length: int = 12
15
+ require_upper: bool = True
16
+ require_lower: bool = True
17
+ require_digit: bool = True
18
+ require_symbol: bool = True
19
+ forbid_common: bool = True
20
+ forbid_breached: bool = True # will toggle off if HIBP integration not configured
21
+ symbols_regex: str = r"[!@#$%^&*()_+=\-{}\[\]:;,.?/]"
22
+
23
+
24
+ class PasswordValidationError(Exception):
25
+ def __init__(self, reasons: Iterable[str]):
26
+ super().__init__("Password validation failed")
27
+ self.reasons = list(reasons)
28
+
29
+
30
+ UPPER = re.compile(r"[A-Z]")
31
+ LOWER = re.compile(r"[a-z]")
32
+ DIGIT = re.compile(r"[0-9]")
33
+ SYMBOL = re.compile(r"[!@#$%^&*()_+=\-{}\[\]:;,.?/]")
34
+
35
+
36
+ BreachedChecker = Callable[[str], bool]
37
+
38
+
39
+ _breached_checker: Optional[BreachedChecker] = None
40
+
41
+
42
+ def configure_breached_checker(checker: Optional[BreachedChecker]) -> None:
43
+ global _breached_checker
44
+ _breached_checker = checker
45
+
46
+
47
+ def validate_password(pw: str, policy: PasswordPolicy | None = None) -> None:
48
+ policy = policy or PasswordPolicy()
49
+ reasons: list[str] = []
50
+ if len(pw) < policy.min_length:
51
+ reasons.append(f"min_length({policy.min_length})")
52
+ if policy.require_upper and not UPPER.search(pw):
53
+ reasons.append("missing_upper")
54
+ if policy.require_lower and not LOWER.search(pw):
55
+ reasons.append("missing_lower")
56
+ if policy.require_digit and not DIGIT.search(pw):
57
+ reasons.append("missing_digit")
58
+ if policy.require_symbol and not SYMBOL.search(pw):
59
+ reasons.append("missing_symbol")
60
+ if policy.forbid_common:
61
+ lowered = pw.lower()
62
+ # Reject if whole password matches a common one or contains it as a substring
63
+ if lowered in COMMON_PASSWORDS or any(
64
+ term in lowered for term in COMMON_PASSWORDS
65
+ ):
66
+ reasons.append("common_password")
67
+ if policy.forbid_breached and not HIBP_DISABLED:
68
+ if _breached_checker and _breached_checker(pw):
69
+ reasons.append("breached_password")
70
+ if reasons:
71
+ raise PasswordValidationError(reasons)
72
+
73
+
74
+ __all__ = [
75
+ "PasswordPolicy",
76
+ "validate_password",
77
+ "PasswordValidationError",
78
+ "configure_breached_checker",
79
+ ]
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import threading
5
+ from typing import Any, Awaitable, Callable, Dict, Iterable, Set
6
+
7
+ from fastapi import Depends, HTTPException
8
+
9
+ from svc_infra.api.fastapi.auth.security import Identity
10
+
11
+ # Thread-safe permission registry
12
+ _PERMISSION_LOCK = threading.Lock()
13
+
14
+ # Central role -> permissions mapping. Projects can extend at startup.
15
+ PERMISSION_REGISTRY: Dict[str, Set[str]] = {
16
+ "admin": {
17
+ "user.read",
18
+ "user.write",
19
+ "billing.read",
20
+ "billing.write",
21
+ "security.session.revoke",
22
+ "security.session.list",
23
+ "admin.impersonate",
24
+ },
25
+ "support": {"user.read", "billing.read"},
26
+ "auditor": {"user.read", "billing.read", "audit.read"},
27
+ }
28
+
29
+
30
+ def register_role(role: str, permissions: Set[str]) -> None:
31
+ """Thread-safe registration of a role and its permissions."""
32
+ with _PERMISSION_LOCK:
33
+ PERMISSION_REGISTRY[role] = permissions
34
+
35
+
36
+ def extend_role(role: str, permissions: Set[str]) -> None:
37
+ """Thread-safe extension of an existing role's permissions."""
38
+ with _PERMISSION_LOCK:
39
+ if role in PERMISSION_REGISTRY:
40
+ PERMISSION_REGISTRY[role] |= permissions
41
+ else:
42
+ PERMISSION_REGISTRY[role] = permissions
43
+
44
+
45
+ def get_permissions_for_roles(roles: Iterable[str]) -> Set[str]:
46
+ perms: Set[str] = set()
47
+ with _PERMISSION_LOCK:
48
+ for r in roles:
49
+ perms |= PERMISSION_REGISTRY.get(r, set())
50
+ return perms
51
+
52
+
53
+ def principal_permissions(principal: Identity) -> Set[str]:
54
+ roles = getattr(principal.user, "roles", []) or []
55
+ return get_permissions_for_roles(roles)
56
+
57
+
58
+ def has_permission(principal: Identity, permission: str) -> bool:
59
+ return permission in principal_permissions(principal)
60
+
61
+
62
+ def RequirePermission(*needed: str):
63
+ """FastAPI dependency enforcing all listed permissions are present."""
64
+
65
+ async def _guard(principal: Identity):
66
+ perms = principal_permissions(principal)
67
+ missing = [p for p in needed if p not in perms]
68
+ if missing:
69
+ raise HTTPException(403, f"missing_permissions:{','.join(missing)}")
70
+ return principal
71
+
72
+ return Depends(_guard)
73
+
74
+
75
+ def RequireAnyPermission(*candidates: str):
76
+ async def _guard(principal: Identity):
77
+ perms = principal_permissions(principal)
78
+ if not (perms & set(candidates)):
79
+ raise HTTPException(403, "insufficient_permissions")
80
+ return principal
81
+
82
+ return Depends(_guard)
83
+
84
+
85
+ # ------- ABAC (Attribute-Based Access Control) helpers -------
86
+ ABACPredicate = Callable[[Identity, Any], bool | Awaitable[bool]]
87
+
88
+
89
+ def owns_resource(attr: str = "owner_id") -> ABACPredicate:
90
+ def _predicate(principal: Identity, resource: Any) -> bool:
91
+ user = getattr(principal, "user", None)
92
+ uid = getattr(user, "id", None)
93
+ rid = getattr(resource, attr, None) or getattr(resource, "user_id", None)
94
+ return bool(uid is not None and rid is not None and str(uid) == str(rid))
95
+
96
+ return _predicate
97
+
98
+
99
+ async def _maybe_await(v):
100
+ if inspect.isawaitable(v):
101
+ return await v
102
+ return v
103
+
104
+
105
+ def enforce_abac(
106
+ principal: Identity,
107
+ *,
108
+ permission: str,
109
+ resource: Any,
110
+ predicate: ABACPredicate,
111
+ ):
112
+ perms = principal_permissions(principal)
113
+ if permission not in perms:
114
+ raise HTTPException(403, f"missing_permissions:{permission}")
115
+ ok = False
116
+ # allow sync or async predicate
117
+ res = predicate(principal, resource)
118
+ if inspect.isawaitable(res):
119
+ # Fast path for sync contexts: raise clear guidance
120
+ raise RuntimeError(
121
+ "enforce_abac received an async predicate in a sync context; use RequireABAC for FastAPI dependencies."
122
+ )
123
+ else:
124
+ ok = bool(res)
125
+ if not ok:
126
+ raise HTTPException(403, "forbidden")
127
+ return principal
128
+
129
+
130
+ def RequireABAC(
131
+ *,
132
+ permission: str,
133
+ predicate: ABACPredicate,
134
+ resource_getter: Callable[..., Any],
135
+ ):
136
+ """FastAPI dependency: enforce permission and attribute check using a resource provider.
137
+
138
+ Example:
139
+ def load_doc(): ...
140
+ @router.get("/docs/{doc_id}", dependencies=[RequireABAC(permission="doc.read", predicate=owns_resource(), resource_getter=load_doc)])
141
+ async def get_doc(identity: Identity, doc = Depends(load_doc)):
142
+ ...
143
+ Note: Using the provider in both the dependency and endpoint will call it twice. For heavy
144
+ providers, wire only in the dependency and re-fetch via the dependency override or request.state.
145
+ """
146
+
147
+ async def _guard(principal: Identity, resource: Any = Depends(resource_getter)):
148
+ perms = principal_permissions(principal)
149
+ if permission not in perms:
150
+ raise HTTPException(403, f"missing_permissions:{permission}")
151
+ ok = await _maybe_await(predicate(principal, resource))
152
+ if not ok:
153
+ raise HTTPException(403, "forbidden")
154
+ return principal
155
+
156
+ return Depends(_guard)
157
+
158
+
159
+ __all__ = [
160
+ "PERMISSION_REGISTRY",
161
+ "register_role",
162
+ "extend_role",
163
+ "get_permissions_for_roles",
164
+ "principal_permissions",
165
+ "has_permission",
166
+ "RequirePermission",
167
+ "RequireAnyPermission",
168
+ "RequireABAC",
169
+ "enforce_abac",
170
+ "owns_resource",
171
+ ]
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Optional
6
+
7
+ try:
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ except Exception: # pragma: no cover
10
+ AsyncSession = object # type: ignore[misc,assignment]
11
+
12
+ from svc_infra.security.models import (
13
+ AuthSession,
14
+ RefreshToken,
15
+ RefreshTokenRevocation,
16
+ generate_refresh_token,
17
+ hash_refresh_token,
18
+ rotate_refresh_token,
19
+ )
20
+
21
+ DEFAULT_REFRESH_TTL_MINUTES = 60 * 24 * 7 # 7 days
22
+
23
+
24
+ async def issue_session_and_refresh(
25
+ db: AsyncSession,
26
+ *,
27
+ user_id: uuid.UUID,
28
+ tenant_id: Optional[str] = None,
29
+ user_agent: Optional[str] = None,
30
+ ip_hash: Optional[str] = None,
31
+ ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
32
+ ) -> tuple[str, RefreshToken]:
33
+ """Persist a new AuthSession + initial RefreshToken and return raw refresh token.
34
+
35
+ Returns: (raw_refresh_token, RefreshToken model instance)
36
+ """
37
+ session_row = AuthSession(
38
+ user_id=user_id,
39
+ tenant_id=tenant_id,
40
+ user_agent=user_agent,
41
+ ip_hash=ip_hash,
42
+ )
43
+ db.add(session_row)
44
+ raw = generate_refresh_token()
45
+ token_hash = hash_refresh_token(raw)
46
+ expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
47
+ rt = RefreshToken(
48
+ session=session_row,
49
+ token_hash=token_hash,
50
+ expires_at=expires_at,
51
+ )
52
+ db.add(rt)
53
+ await db.flush()
54
+ return raw, rt
55
+
56
+
57
+ async def rotate_session_refresh(
58
+ db: AsyncSession,
59
+ *,
60
+ current: RefreshToken,
61
+ ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
62
+ ) -> tuple[str, RefreshToken]:
63
+ """Rotate a session's refresh token: mark current rotated, create new, add revocation record.
64
+
65
+ Returns: (new_raw_refresh_token, new_refresh_token_model)
66
+ """
67
+ rotation_ts = datetime.now(timezone.utc)
68
+ if current.revoked_at:
69
+ raise ValueError("refresh token already revoked")
70
+ if current.expires_at and current.expires_at <= rotation_ts:
71
+ raise ValueError("refresh token expired")
72
+ new_raw, new_hash, expires_at = rotate_refresh_token(
73
+ current.token_hash, ttl_minutes=ttl_minutes
74
+ )
75
+ current.rotated_at = rotation_ts
76
+ current.revoked_at = rotation_ts
77
+ current.revoke_reason = "rotated"
78
+ if current.expires_at is None or current.expires_at > rotation_ts:
79
+ current.expires_at = rotation_ts
80
+ # create revocation entry for old hash
81
+ db.add(
82
+ RefreshTokenRevocation(
83
+ token_hash=current.token_hash,
84
+ revoked_at=rotation_ts,
85
+ reason="rotated",
86
+ )
87
+ )
88
+ new_row = RefreshToken(
89
+ session=current.session,
90
+ token_hash=new_hash,
91
+ expires_at=expires_at,
92
+ )
93
+ db.add(new_row)
94
+ await db.flush()
95
+ return new_raw, new_row
96
+
97
+
98
+ __all__ = ["issue_session_and_refresh", "rotate_session_refresh"]
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hmac
5
+ import json
6
+ import time
7
+ from hashlib import sha256
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+
11
+ def _b64e(b: bytes) -> str:
12
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
13
+
14
+
15
+ def _b64d(s: str) -> bytes:
16
+ pad = "=" * (-len(s) % 4)
17
+ return base64.urlsafe_b64decode((s + pad).encode())
18
+
19
+
20
+ def _sign(data: bytes, key: bytes) -> str:
21
+ return _b64e(hmac.new(key, data, sha256).digest())
22
+
23
+
24
+ def _now() -> int:
25
+ return int(time.time())
26
+
27
+
28
+ def sign_cookie(
29
+ payload: Dict[str, Any],
30
+ *,
31
+ key: str,
32
+ expires_in: Optional[int] = None,
33
+ path: Optional[str] = None,
34
+ domain: Optional[str] = None,
35
+ ) -> str:
36
+ """Produce a compact signed cookie value with optional expiry and scope binding.
37
+
38
+ Format: base64url(json).base64url(hmac)
39
+ If expires_in is provided, 'exp' epoch seconds is injected into payload prior to signing.
40
+ If path or domain is provided, they are included in the signed payload to prevent
41
+ cookie replay attacks across different paths/domains.
42
+ """
43
+ body = dict(payload)
44
+ if expires_in is not None:
45
+ body.setdefault("exp", _now() + int(expires_in))
46
+ # Include scope in signature to prevent replay across paths/domains
47
+ if path is not None:
48
+ body.setdefault("_path", path)
49
+ if domain is not None:
50
+ body.setdefault("_domain", domain)
51
+ data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
52
+ sig = _sign(data, key.encode())
53
+ return f"{_b64e(data)}.{sig}"
54
+
55
+
56
+ def verify_cookie(
57
+ value: str,
58
+ *,
59
+ key: str,
60
+ old_keys: Optional[List[str]] = None,
61
+ expected_path: Optional[str] = None,
62
+ expected_domain: Optional[str] = None,
63
+ ) -> Tuple[bool, Optional[Dict[str, Any]]]:
64
+ """Verify a signed cookie against the primary key or any old key.
65
+
66
+ Returns (ok, payload). If ok is False, payload will be None.
67
+ Rejects if exp is present and in the past.
68
+ If expected_path or expected_domain is provided, verifies the cookie was signed
69
+ for that scope (prevents replay attacks across paths/domains).
70
+ """
71
+ if not value or "." not in value:
72
+ return False, None
73
+ body_b64, sig = value.split(".", 1)
74
+ try:
75
+ data = _b64d(body_b64)
76
+ expected = _sign(data, key.encode())
77
+ if not hmac.compare_digest(sig, expected):
78
+ # try old keys
79
+ for k in old_keys or []:
80
+ if hmac.compare_digest(sig, _sign(data, k.encode())):
81
+ break
82
+ else:
83
+ return False, None
84
+ payload = json.loads(data.decode())
85
+ # Expire when current time reaches or exceeds exp
86
+ if "exp" in payload and _now() >= int(payload["exp"]):
87
+ return False, None
88
+ # Verify scope binding if expected
89
+ if expected_path is not None:
90
+ if payload.get("_path") != expected_path:
91
+ return False, None
92
+ if expected_domain is not None:
93
+ if payload.get("_domain") != expected_domain:
94
+ return False, None
95
+ return True, payload
96
+ except Exception:
97
+ return False, None
98
+
99
+
100
+ __all__ = ["sign_cookie", "verify_cookie"]
@@ -0,0 +1,93 @@
1
+ """
2
+ Generic file storage system for svc-infra.
3
+
4
+ Provides backend-agnostic file storage with support for multiple providers:
5
+ - Local filesystem (Railway volumes, Render, development)
6
+ - S3-compatible (AWS S3, DigitalOcean Spaces, Wasabi, Backblaze B2, Minio)
7
+ - Google Cloud Storage (coming soon)
8
+ - Cloudinary (coming soon)
9
+ - In-memory (testing)
10
+
11
+ Quick Start:
12
+ >>> from svc_infra.storage import add_storage, easy_storage
13
+ >>> from fastapi import FastAPI
14
+ >>>
15
+ >>> app = FastAPI()
16
+ >>>
17
+ >>> # Auto-detect backend from environment
18
+ >>> storage = add_storage(app)
19
+ >>>
20
+ >>> # Or explicit backend
21
+ >>> backend = easy_storage(backend="s3", bucket="my-uploads")
22
+ >>> storage = add_storage(app, backend)
23
+
24
+ Usage in Routes:
25
+ >>> from svc_infra.storage import get_storage, StorageBackend
26
+ >>> from fastapi import Depends, UploadFile
27
+ >>>
28
+ >>> @router.post("/upload")
29
+ >>> async def upload_file(
30
+ ... file: UploadFile,
31
+ ... storage: StorageBackend = Depends(get_storage),
32
+ ... ):
33
+ ... content = await file.read()
34
+ ... url = await storage.put(
35
+ ... key=f"uploads/{file.filename}",
36
+ ... data=content,
37
+ ... content_type=file.content_type or "application/octet-stream",
38
+ ... metadata={"user_id": "user_123"}
39
+ ... )
40
+ ... return {"url": url}
41
+
42
+ Environment Variables:
43
+ STORAGE_BACKEND: Backend type (local, s3, gcs, cloudinary, memory)
44
+
45
+ Local:
46
+ STORAGE_BASE_PATH: Directory for files (default: /data/uploads)
47
+ STORAGE_BASE_URL: URL for file serving (default: http://localhost:8000/files)
48
+
49
+ S3:
50
+ STORAGE_S3_BUCKET: Bucket name (required)
51
+ STORAGE_S3_REGION: AWS region (default: us-east-1)
52
+ STORAGE_S3_ENDPOINT: Custom endpoint for S3-compatible services
53
+ STORAGE_S3_ACCESS_KEY: Access key (falls back to AWS_ACCESS_KEY_ID)
54
+ STORAGE_S3_SECRET_KEY: Secret key (falls back to AWS_SECRET_ACCESS_KEY)
55
+
56
+ See Also:
57
+ - ADR-0012: Generic File Storage System design
58
+ - docs/storage.md: Comprehensive storage guide
59
+ """
60
+
61
+ from .add import add_storage, get_storage, health_check_storage
62
+ from .backends import LocalBackend, MemoryBackend, S3Backend
63
+ from .base import (
64
+ FileNotFoundError,
65
+ InvalidKeyError,
66
+ PermissionDeniedError,
67
+ QuotaExceededError,
68
+ StorageBackend,
69
+ StorageError,
70
+ )
71
+ from .easy import easy_storage
72
+ from .settings import StorageSettings
73
+
74
+ __all__ = [
75
+ # Main API
76
+ "add_storage",
77
+ "easy_storage",
78
+ "get_storage",
79
+ "health_check_storage",
80
+ # Base types
81
+ "StorageBackend",
82
+ "StorageSettings",
83
+ # Backends
84
+ "LocalBackend",
85
+ "MemoryBackend",
86
+ "S3Backend",
87
+ # Exceptions
88
+ "StorageError",
89
+ "FileNotFoundError",
90
+ "PermissionDeniedError",
91
+ "QuotaExceededError",
92
+ "InvalidKeyError",
93
+ ]