svc-infra 0.1.595__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 (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -6,7 +6,17 @@ import uuid
6
6
  from datetime import datetime, timedelta, timezone
7
7
  from typing import Optional
8
8
 
9
- from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint
9
+ from sqlalchemy import (
10
+ JSON,
11
+ Boolean,
12
+ DateTime,
13
+ ForeignKey,
14
+ Index,
15
+ String,
16
+ Text,
17
+ UniqueConstraint,
18
+ text,
19
+ )
10
20
  from sqlalchemy.orm import Mapped, mapped_column, relationship
11
21
 
12
22
  from svc_infra.db.sql.base import ModelBase
@@ -34,7 +44,9 @@ class AuthSession(ModelBase):
34
44
  )
35
45
 
36
46
  created_at = mapped_column(
37
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
47
+ DateTime(timezone=True),
48
+ server_default=text("CURRENT_TIMESTAMP"),
49
+ nullable=False,
38
50
  )
39
51
 
40
52
 
@@ -51,10 +63,14 @@ class RefreshToken(ModelBase):
51
63
  rotated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
52
64
  revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
53
65
  revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
54
- expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
66
+ expires_at: Mapped[Optional[datetime]] = mapped_column(
67
+ DateTime(timezone=True), index=True
68
+ )
55
69
 
56
70
  created_at = mapped_column(
57
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
71
+ DateTime(timezone=True),
72
+ server_default=text("CURRENT_TIMESTAMP"),
73
+ nullable=False,
58
74
  )
59
75
 
60
76
  __table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
@@ -65,7 +81,9 @@ class RefreshTokenRevocation(ModelBase):
65
81
 
66
82
  id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
67
83
  token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
68
- revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
84
+ revoked_at: Mapped[datetime] = mapped_column(
85
+ DateTime(timezone=True), nullable=False
86
+ )
69
87
  reason: Mapped[Optional[str]] = mapped_column(Text)
70
88
 
71
89
 
@@ -78,7 +96,9 @@ class FailedAuthAttempt(ModelBase):
78
96
  )
79
97
  ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
80
98
  ts: Mapped[datetime] = mapped_column(
81
- DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
99
+ DateTime(timezone=True),
100
+ nullable=False,
101
+ default=lambda: datetime.now(timezone.utc),
82
102
  )
83
103
  success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
84
104
 
@@ -126,7 +146,9 @@ class Organization(ModelBase):
126
146
  slug: Mapped[Optional[str]] = mapped_column(String(64), index=True)
127
147
  tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
128
148
  created_at = mapped_column(
129
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
149
+ DateTime(timezone=True),
150
+ server_default=text("CURRENT_TIMESTAMP"),
151
+ nullable=False,
130
152
  )
131
153
 
132
154
 
@@ -139,7 +161,9 @@ class Team(ModelBase):
139
161
  )
140
162
  name: Mapped[str] = mapped_column(String(128), nullable=False)
141
163
  created_at = mapped_column(
142
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
164
+ DateTime(timezone=True),
165
+ server_default=text("CURRENT_TIMESTAMP"),
166
+ nullable=False,
143
167
  )
144
168
 
145
169
 
@@ -155,11 +179,15 @@ class OrganizationMembership(ModelBase):
155
179
  )
156
180
  role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
157
181
  created_at = mapped_column(
158
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
182
+ DateTime(timezone=True),
183
+ server_default=text("CURRENT_TIMESTAMP"),
184
+ nullable=False,
159
185
  )
160
186
  deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
161
187
 
162
- __table_args__ = (UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),)
188
+ __table_args__ = (
189
+ UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),
190
+ )
163
191
 
164
192
 
165
193
  class OrganizationInvitation(ModelBase):
@@ -172,12 +200,16 @@ class OrganizationInvitation(ModelBase):
172
200
  email: Mapped[str] = mapped_column(String(255), index=True)
173
201
  role: Mapped[str] = mapped_column(String(64), nullable=False)
174
202
  token_hash: Mapped[str] = mapped_column(String(64), index=True)
175
- expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
203
+ expires_at: Mapped[Optional[datetime]] = mapped_column(
204
+ DateTime(timezone=True), index=True
205
+ )
176
206
  created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
177
207
  GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
178
208
  )
179
209
  created_at = mapped_column(
180
- DateTime(timezone=True), server_default="CURRENT_TIMESTAMP", nullable=False
210
+ DateTime(timezone=True),
211
+ server_default=text("CURRENT_TIMESTAMP"),
212
+ nullable=False,
181
213
  )
182
214
  last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
183
215
  resend_count: Mapped[int] = mapped_column(default=0)
@@ -185,6 +217,11 @@ class OrganizationInvitation(ModelBase):
185
217
  revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
186
218
 
187
219
 
220
+ # ------------------------ OAuth Provider Accounts -----------------------------
221
+ # MOVED to svc_infra.security.oauth_models for opt-in OAuth support
222
+ # Projects that enable OAuth should import ProviderAccount from there
223
+
224
+
188
225
  # ------------------------ Utilities -------------------------------------------
189
226
 
190
227
 
@@ -238,6 +275,11 @@ __all__ = [
238
275
  "FailedAuthAttempt",
239
276
  "RolePermission",
240
277
  "AuditLog",
278
+ "Organization",
279
+ "Team",
280
+ "OrganizationMembership",
281
+ "OrganizationInvitation",
282
+ # ProviderAccount moved to svc_infra.security.oauth_models (opt-in)
241
283
  "generate_refresh_token",
242
284
  "hash_refresh_token",
243
285
  "compute_audit_hash",
@@ -0,0 +1,73 @@
1
+ """
2
+ OAuth provider account models (opt-in).
3
+
4
+ These models are only registered when a project explicitly enables OAuth.
5
+ Import this module only when enable_oauth=True is passed to add_auth_users.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from typing import TYPE_CHECKING, Optional
13
+
14
+ from sqlalchemy import (
15
+ JSON,
16
+ DateTime,
17
+ ForeignKey,
18
+ Index,
19
+ String,
20
+ Text,
21
+ UniqueConstraint,
22
+ text,
23
+ )
24
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
25
+
26
+ from svc_infra.db.sql.base import ModelBase
27
+ from svc_infra.db.sql.types import GUID
28
+
29
+ if TYPE_CHECKING:
30
+ from typing import Any
31
+
32
+ # User model is application-specific; this is a forward reference for type hints
33
+ User = Any
34
+
35
+
36
+ class ProviderAccount(ModelBase):
37
+ """OAuth provider account linking (Google, GitHub, etc.)."""
38
+
39
+ __tablename__ = "provider_accounts"
40
+
41
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
42
+ user_id: Mapped[uuid.UUID] = mapped_column(
43
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False
44
+ )
45
+ provider: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
46
+ provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
47
+ access_token: Mapped[Optional[str]] = mapped_column(Text)
48
+ refresh_token: Mapped[Optional[str]] = mapped_column(Text)
49
+ expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
50
+ raw_claims: Mapped[Optional[dict]] = mapped_column(JSON)
51
+
52
+ # Bidirectional relationship to User model
53
+ user: Mapped["User"] = relationship(back_populates="provider_accounts")
54
+
55
+ created_at = mapped_column(
56
+ DateTime(timezone=True),
57
+ server_default=text("CURRENT_TIMESTAMP"),
58
+ nullable=False,
59
+ )
60
+ updated_at = mapped_column(
61
+ DateTime(timezone=True),
62
+ server_default=text("CURRENT_TIMESTAMP"),
63
+ onupdate=lambda: datetime.now(timezone.utc),
64
+ nullable=False,
65
+ )
66
+
67
+ __table_args__ = (
68
+ UniqueConstraint("provider", "provider_account_id", name="uq_provider_account"),
69
+ Index("ix_provider_accounts_user_provider", "user_id", "provider"),
70
+ )
71
+
72
+
73
+ __all__ = ["ProviderAccount"]
@@ -9,8 +9,8 @@ try:
9
9
  from sqlalchemy import select
10
10
  from sqlalchemy.ext.asyncio import AsyncSession
11
11
  except Exception: # pragma: no cover
12
- AsyncSession = object # type: ignore
13
- select = None # type: ignore
12
+ AsyncSession = object # type: ignore[misc,assignment]
13
+ select = None # type: ignore[assignment]
14
14
 
15
15
  from .models import OrganizationInvitation, OrganizationMembership
16
16
 
@@ -113,7 +113,9 @@ async def accept_invitation(
113
113
  invitation.used_at = now
114
114
 
115
115
  # create membership (upsert-like enforced by DB unique constraint)
116
- mem = OrganizationMembership(org_id=invitation.org_id, user_id=user_id, role=invitation.role)
116
+ mem = OrganizationMembership(
117
+ org_id=invitation.org_id, user_id=user_id, role=invitation.role
118
+ )
117
119
  if hasattr(db, "add"):
118
120
  db.add(mem)
119
121
  if hasattr(db, "flush"):
@@ -60,7 +60,9 @@ def validate_password(pw: str, policy: PasswordPolicy | None = None) -> None:
60
60
  if policy.forbid_common:
61
61
  lowered = pw.lower()
62
62
  # Reject if whole password matches a common one or contains it as a substring
63
- if lowered in COMMON_PASSWORDS or any(term in lowered for term in COMMON_PASSWORDS):
63
+ if lowered in COMMON_PASSWORDS or any(
64
+ term in lowered for term in COMMON_PASSWORDS
65
+ ):
64
66
  reasons.append("common_password")
65
67
  if policy.forbid_breached and not HIBP_DISABLED:
66
68
  if _breached_checker and _breached_checker(pw):
@@ -1,12 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
+ import threading
4
5
  from typing import Any, Awaitable, Callable, Dict, Iterable, Set
5
6
 
6
7
  from fastapi import Depends, HTTPException
7
8
 
8
9
  from svc_infra.api.fastapi.auth.security import Identity
9
10
 
11
+ # Thread-safe permission registry
12
+ _PERMISSION_LOCK = threading.Lock()
13
+
10
14
  # Central role -> permissions mapping. Projects can extend at startup.
11
15
  PERMISSION_REGISTRY: Dict[str, Set[str]] = {
12
16
  "admin": {
@@ -16,16 +20,33 @@ PERMISSION_REGISTRY: Dict[str, Set[str]] = {
16
20
  "billing.write",
17
21
  "security.session.revoke",
18
22
  "security.session.list",
23
+ "admin.impersonate",
19
24
  },
20
25
  "support": {"user.read", "billing.read"},
21
26
  "auditor": {"user.read", "billing.read", "audit.read"},
22
27
  }
23
28
 
24
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
+
25
45
  def get_permissions_for_roles(roles: Iterable[str]) -> Set[str]:
26
46
  perms: Set[str] = set()
27
- for r in roles:
28
- perms |= PERMISSION_REGISTRY.get(r, set())
47
+ with _PERMISSION_LOCK:
48
+ for r in roles:
49
+ perms |= PERMISSION_REGISTRY.get(r, set())
29
50
  return perms
30
51
 
31
52
 
@@ -137,6 +158,8 @@ def RequireABAC(
137
158
 
138
159
  __all__ = [
139
160
  "PERMISSION_REGISTRY",
161
+ "register_role",
162
+ "extend_role",
140
163
  "get_permissions_for_roles",
141
164
  "principal_permissions",
142
165
  "has_permission",
@@ -7,7 +7,7 @@ from typing import Optional
7
7
  try:
8
8
  from sqlalchemy.ext.asyncio import AsyncSession
9
9
  except Exception: # pragma: no cover
10
- AsyncSession = object # type: ignore
10
+ AsyncSession = object # type: ignore[misc,assignment]
11
11
 
12
12
  from svc_infra.security.models import (
13
13
  AuthSession,
@@ -30,15 +30,24 @@ def sign_cookie(
30
30
  *,
31
31
  key: str,
32
32
  expires_in: Optional[int] = None,
33
+ path: Optional[str] = None,
34
+ domain: Optional[str] = None,
33
35
  ) -> str:
34
- """Produce a compact signed cookie value with optional expiry.
36
+ """Produce a compact signed cookie value with optional expiry and scope binding.
35
37
 
36
38
  Format: base64url(json).base64url(hmac)
37
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.
38
42
  """
39
43
  body = dict(payload)
40
44
  if expires_in is not None:
41
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)
42
51
  data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
43
52
  sig = _sign(data, key.encode())
44
53
  return f"{_b64e(data)}.{sig}"
@@ -49,11 +58,15 @@ def verify_cookie(
49
58
  *,
50
59
  key: str,
51
60
  old_keys: Optional[List[str]] = None,
61
+ expected_path: Optional[str] = None,
62
+ expected_domain: Optional[str] = None,
52
63
  ) -> Tuple[bool, Optional[Dict[str, Any]]]:
53
64
  """Verify a signed cookie against the primary key or any old key.
54
65
 
55
66
  Returns (ok, payload). If ok is False, payload will be None.
56
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).
57
70
  """
58
71
  if not value or "." not in value:
59
72
  return False, None
@@ -72,6 +85,13 @@ def verify_cookie(
72
85
  # Expire when current time reaches or exceeds exp
73
86
  if "exp" in payload and _now() >= int(payload["exp"]):
74
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
75
95
  return True, payload
76
96
  except Exception:
77
97
  return False, None
@@ -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
+ ]