svc-infra 0.1.562__py3-none-any.whl → 0.1.654__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 (175) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/models.py +142 -4
  3. svc_infra/apf_payments/provider/__init__.py +4 -0
  4. svc_infra/apf_payments/provider/aiydan.py +797 -0
  5. svc_infra/apf_payments/provider/base.py +178 -12
  6. svc_infra/apf_payments/provider/stripe.py +757 -48
  7. svc_infra/apf_payments/schemas.py +163 -1
  8. svc_infra/apf_payments/service.py +582 -42
  9. svc_infra/apf_payments/settings.py +22 -2
  10. svc_infra/api/fastapi/admin/__init__.py +3 -0
  11. svc_infra/api/fastapi/admin/add.py +231 -0
  12. svc_infra/api/fastapi/apf_payments/router.py +792 -73
  13. svc_infra/api/fastapi/apf_payments/setup.py +13 -4
  14. svc_infra/api/fastapi/auth/add.py +10 -4
  15. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  16. svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
  17. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  18. svc_infra/api/fastapi/auth/settings.py +2 -0
  19. svc_infra/api/fastapi/billing/router.py +64 -0
  20. svc_infra/api/fastapi/billing/setup.py +19 -0
  21. svc_infra/api/fastapi/cache/add.py +9 -5
  22. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  23. svc_infra/api/fastapi/db/sql/add.py +40 -18
  24. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  25. svc_infra/api/fastapi/db/sql/session.py +16 -0
  26. svc_infra/api/fastapi/db/sql/users.py +13 -1
  27. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  28. svc_infra/api/fastapi/docs/add.py +160 -0
  29. svc_infra/api/fastapi/docs/landing.py +1 -1
  30. svc_infra/api/fastapi/docs/scoped.py +41 -6
  31. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  32. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  33. svc_infra/api/fastapi/middleware/idempotency.py +82 -42
  34. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  35. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  36. svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
  37. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  38. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  39. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  40. svc_infra/api/fastapi/openapi/mutators.py +244 -38
  41. svc_infra/api/fastapi/ops/add.py +73 -0
  42. svc_infra/api/fastapi/pagination.py +133 -32
  43. svc_infra/api/fastapi/routers/ping.py +1 -0
  44. svc_infra/api/fastapi/setup.py +23 -14
  45. svc_infra/api/fastapi/tenancy/add.py +19 -0
  46. svc_infra/api/fastapi/tenancy/context.py +112 -0
  47. svc_infra/api/fastapi/versioned.py +101 -0
  48. svc_infra/app/README.md +5 -5
  49. svc_infra/billing/__init__.py +23 -0
  50. svc_infra/billing/async_service.py +147 -0
  51. svc_infra/billing/jobs.py +230 -0
  52. svc_infra/billing/models.py +131 -0
  53. svc_infra/billing/quotas.py +101 -0
  54. svc_infra/billing/schemas.py +33 -0
  55. svc_infra/billing/service.py +115 -0
  56. svc_infra/bundled_docs/README.md +5 -0
  57. svc_infra/bundled_docs/__init__.py +1 -0
  58. svc_infra/bundled_docs/getting-started.md +6 -0
  59. svc_infra/cache/__init__.py +4 -0
  60. svc_infra/cache/add.py +158 -0
  61. svc_infra/cache/backend.py +5 -2
  62. svc_infra/cache/decorators.py +19 -1
  63. svc_infra/cache/keys.py +24 -4
  64. svc_infra/cli/__init__.py +32 -8
  65. svc_infra/cli/__main__.py +4 -0
  66. svc_infra/cli/cmds/__init__.py +10 -0
  67. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  68. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  69. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  70. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  71. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  72. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  73. svc_infra/cli/cmds/dx/__init__.py +12 -0
  74. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  75. svc_infra/cli/cmds/help.py +4 -0
  76. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  77. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  78. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  79. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  80. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  81. svc_infra/data/add.py +61 -0
  82. svc_infra/data/backup.py +53 -0
  83. svc_infra/data/erasure.py +45 -0
  84. svc_infra/data/fixtures.py +40 -0
  85. svc_infra/data/retention.py +55 -0
  86. svc_infra/db/inbox.py +67 -0
  87. svc_infra/db/nosql/mongo/README.md +13 -13
  88. svc_infra/db/outbox.py +104 -0
  89. svc_infra/db/sql/repository.py +52 -12
  90. svc_infra/db/sql/resource.py +5 -0
  91. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  92. svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
  93. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
  94. svc_infra/db/sql/tenant.py +79 -0
  95. svc_infra/db/sql/utils.py +18 -4
  96. svc_infra/db/sql/versioning.py +14 -0
  97. svc_infra/docs/acceptance-matrix.md +71 -0
  98. svc_infra/docs/acceptance.md +44 -0
  99. svc_infra/docs/admin.md +425 -0
  100. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  101. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  102. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  103. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  104. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  105. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  106. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  107. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  108. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  109. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  110. svc_infra/docs/api.md +59 -0
  111. svc_infra/docs/auth.md +11 -0
  112. svc_infra/docs/billing.md +190 -0
  113. svc_infra/docs/cache.md +76 -0
  114. svc_infra/docs/cli.md +74 -0
  115. svc_infra/docs/contributing.md +34 -0
  116. svc_infra/docs/data-lifecycle.md +52 -0
  117. svc_infra/docs/database.md +14 -0
  118. svc_infra/docs/docs-and-sdks.md +62 -0
  119. svc_infra/docs/environment.md +114 -0
  120. svc_infra/docs/getting-started.md +63 -0
  121. svc_infra/docs/idempotency.md +111 -0
  122. svc_infra/docs/jobs.md +67 -0
  123. svc_infra/docs/observability.md +16 -0
  124. svc_infra/docs/ops.md +37 -0
  125. svc_infra/docs/rate-limiting.md +125 -0
  126. svc_infra/docs/repo-review.md +48 -0
  127. svc_infra/docs/security.md +176 -0
  128. svc_infra/docs/tenancy.md +35 -0
  129. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  130. svc_infra/docs/versioned-integrations.md +146 -0
  131. svc_infra/docs/webhooks.md +112 -0
  132. svc_infra/dx/add.py +63 -0
  133. svc_infra/dx/changelog.py +74 -0
  134. svc_infra/dx/checks.py +67 -0
  135. svc_infra/http/__init__.py +13 -0
  136. svc_infra/http/client.py +72 -0
  137. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  138. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  139. svc_infra/jobs/easy.py +32 -0
  140. svc_infra/jobs/loader.py +45 -0
  141. svc_infra/jobs/queue.py +81 -0
  142. svc_infra/jobs/redis_queue.py +191 -0
  143. svc_infra/jobs/runner.py +75 -0
  144. svc_infra/jobs/scheduler.py +41 -0
  145. svc_infra/jobs/worker.py +40 -0
  146. svc_infra/mcp/svc_infra_mcp.py +85 -28
  147. svc_infra/obs/README.md +2 -0
  148. svc_infra/obs/add.py +54 -7
  149. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  150. svc_infra/obs/metrics/__init__.py +53 -0
  151. svc_infra/obs/metrics.py +52 -0
  152. svc_infra/security/add.py +201 -0
  153. svc_infra/security/audit.py +130 -0
  154. svc_infra/security/audit_service.py +73 -0
  155. svc_infra/security/headers.py +52 -0
  156. svc_infra/security/hibp.py +95 -0
  157. svc_infra/security/jwt_rotation.py +53 -0
  158. svc_infra/security/lockout.py +96 -0
  159. svc_infra/security/models.py +255 -0
  160. svc_infra/security/org_invites.py +128 -0
  161. svc_infra/security/passwords.py +77 -0
  162. svc_infra/security/permissions.py +149 -0
  163. svc_infra/security/session.py +98 -0
  164. svc_infra/security/signed_cookies.py +80 -0
  165. svc_infra/webhooks/__init__.py +16 -0
  166. svc_infra/webhooks/add.py +322 -0
  167. svc_infra/webhooks/fastapi.py +37 -0
  168. svc_infra/webhooks/router.py +55 -0
  169. svc_infra/webhooks/service.py +67 -0
  170. svc_infra/webhooks/signing.py +30 -0
  171. svc_infra-0.1.654.dist-info/METADATA +154 -0
  172. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
  173. svc_infra-0.1.562.dist-info/METADATA +0 -79
  174. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  175. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -9,17 +9,26 @@ STRIPE_KEY = os.getenv("STRIPE_SECRET") or os.getenv("STRIPE_API_KEY")
9
9
  STRIPE_WH = os.getenv("STRIPE_WH_SECRET")
10
10
  PROVIDER = (os.getenv("APF_PAYMENTS_PROVIDER") or os.getenv("PAYMENTS_PROVIDER", "stripe")).lower()
11
11
 
12
+ AIYDAN_KEY = os.getenv("AIYDAN_API_KEY")
13
+ AIYDAN_CLIENT_KEY = os.getenv("AIYDAN_CLIENT_KEY")
14
+ AIYDAN_MERCHANT = os.getenv("AIYDAN_MERCHANT_ACCOUNT")
15
+ AIYDAN_HMAC = os.getenv("AIYDAN_HMAC_KEY")
16
+ AIYDAN_BASE_URL = os.getenv("AIYDAN_BASE_URL")
17
+ AIYDAN_WH = os.getenv("AIYDAN_WH_SECRET")
18
+
12
19
 
13
20
  class StripeConfig(BaseModel):
14
21
  secret_key: SecretStr
15
22
  webhook_secret: Optional[SecretStr] = None
16
23
 
17
24
 
18
- class AdyenConfig(BaseModel):
25
+ class AiydanConfig(BaseModel):
19
26
  api_key: SecretStr
20
27
  client_key: Optional[SecretStr] = None
21
28
  merchant_account: Optional[str] = None
22
29
  hmac_key: Optional[SecretStr] = None
30
+ base_url: Optional[str] = None
31
+ webhook_secret: Optional[SecretStr] = None
23
32
 
24
33
 
25
34
  class PaymentsSettings(BaseModel):
@@ -34,7 +43,18 @@ class PaymentsSettings(BaseModel):
34
43
  if STRIPE_KEY
35
44
  else None
36
45
  )
37
- adyen: Optional[AdyenConfig] = None
46
+ aiydan: Optional[AiydanConfig] = (
47
+ AiydanConfig(
48
+ api_key=SecretStr(AIYDAN_KEY),
49
+ client_key=SecretStr(AIYDAN_CLIENT_KEY) if AIYDAN_CLIENT_KEY else None,
50
+ merchant_account=AIYDAN_MERCHANT,
51
+ hmac_key=SecretStr(AIYDAN_HMAC) if AIYDAN_HMAC else None,
52
+ base_url=AIYDAN_BASE_URL,
53
+ webhook_secret=SecretStr(AIYDAN_WH) if AIYDAN_WH else None,
54
+ )
55
+ if AIYDAN_KEY
56
+ else None
57
+ )
38
58
 
39
59
 
40
60
  _SETTINGS: Optional[PaymentsSettings] = None
@@ -0,0 +1,3 @@
1
+ from .add import add_admin, admin_router
2
+
3
+ __all__ = ["add_admin", "admin_router"]
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hmac
5
+ import inspect
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from hashlib import sha256
11
+ from types import SimpleNamespace
12
+ from typing import Any, Callable, Optional
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
15
+
16
+ from ....app.env import get_current_environment
17
+ from ....security.permissions import RequirePermission
18
+ from ..auth.security import Identity, Principal, _current_principal
19
+ from ..auth.state import get_auth_state
20
+ from ..db.sql.session import SqlSessionDep
21
+ from ..dual.protected import roles_router
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def _b64u(data: bytes) -> str:
27
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
28
+
29
+
30
+ def _b64u_decode(s: str) -> bytes:
31
+ pad = "=" * ((4 - len(s) % 4) % 4)
32
+ return base64.urlsafe_b64decode(s + pad)
33
+
34
+
35
+ def _sign(payload: dict, *, secret: str) -> str:
36
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
37
+ sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
38
+ return _b64u(body) + "." + _b64u(sig)
39
+
40
+
41
+ def _verify(token: str, *, secret: str) -> dict:
42
+ try:
43
+ b64_body, b64_sig = token.split(".", 1)
44
+ body = _b64u_decode(b64_body)
45
+ exp_sig = _b64u_decode(b64_sig)
46
+ got_sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
47
+ if not hmac.compare_digest(exp_sig, got_sig):
48
+ raise ValueError("bad_signature")
49
+ payload = json.loads(body)
50
+ if int(payload.get("exp", 0)) < int(time.time()):
51
+ raise ValueError("expired")
52
+ return payload
53
+ except Exception as e:
54
+ raise ValueError("invalid_token") from e
55
+
56
+
57
+ def admin_router(*, dependencies: Optional[list[Any]] = None, **kwargs) -> APIRouter:
58
+ """Role-gated admin router for coarse access control.
59
+
60
+ Use permission guards inside endpoints for fine-grained control.
61
+ """
62
+
63
+ return roles_router("admin", **kwargs)
64
+
65
+
66
+ def add_admin(
67
+ app,
68
+ *,
69
+ base_path: str = "/admin",
70
+ enable_impersonation: bool = True,
71
+ secret: Optional[str] = None,
72
+ ttl_seconds: int = 15 * 60,
73
+ cookie_name: str = "impersonation",
74
+ impersonation_user_getter: Optional[Callable[[Any, str], Any]] = None,
75
+ ) -> None:
76
+ """Wire admin surfaces with sensible defaults.
77
+
78
+ - Mounts an admin router under base_path.
79
+ - Optionally enables impersonation start/stop endpoints guarded by permissions.
80
+ - Registers a dependency override to honor impersonation cookie globally (idempotent).
81
+
82
+ impersonation_user_getter: optional callable (request, user_id) -> user object.
83
+ If omitted, defaults to loading from SQLAlchemy User model returned by get_auth_state().
84
+ """
85
+
86
+ # Idempotency: only mount once per app instance
87
+ if getattr(app.state, "_admin_added", False):
88
+ return
89
+
90
+ env = get_current_environment()
91
+ _secret = (
92
+ secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET") or "dev-secret"
93
+ )
94
+ _ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
95
+ _cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
96
+
97
+ r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
98
+
99
+ async def _default_user_getter(request: Request, user_id: str, session: SqlSessionDep):
100
+ try:
101
+ UserModel, _, _ = get_auth_state()
102
+ except Exception:
103
+ # Fallback: simple shim if auth state not configured
104
+ return SimpleNamespace(id=user_id)
105
+ obj = await session.get(UserModel, user_id)
106
+ if not obj:
107
+ raise HTTPException(404, "user_not_found")
108
+ return obj
109
+
110
+ user_getter = impersonation_user_getter
111
+
112
+ @r.post(
113
+ "/impersonate/start", status_code=204, dependencies=[RequirePermission("admin.impersonate")]
114
+ )
115
+ async def start_impersonation(
116
+ body: dict, request: Request, response: Response, session: SqlSessionDep, identity: Identity
117
+ ):
118
+ target_id = (body or {}).get("user_id")
119
+ reason = (body or {}).get("reason", "")
120
+ if not target_id:
121
+ raise HTTPException(422, "user_id_required")
122
+ # Load target for validation (custom getter or default)
123
+ _res = (
124
+ user_getter(request, target_id)
125
+ if user_getter
126
+ else _default_user_getter(request, target_id, session)
127
+ )
128
+ target = await _res if inspect.isawaitable(_res) else _res
129
+ actor: Principal = identity
130
+ payload = {
131
+ "actor_id": getattr(getattr(actor, "user", None), "id", None),
132
+ "target_id": str(getattr(target, "id", target_id)),
133
+ "iat": int(time.time()),
134
+ "exp": int(time.time()) + _ttl,
135
+ "nonce": _b64u(os.urandom(8)),
136
+ }
137
+ token = _sign(payload, secret=_secret)
138
+ response.set_cookie(
139
+ key=_cookie,
140
+ value=token,
141
+ httponly=True,
142
+ samesite="lax",
143
+ secure=(env in ("prod", "production")),
144
+ path="/",
145
+ max_age=_ttl,
146
+ )
147
+ logger.info(
148
+ "admin.impersonation.started",
149
+ extra={
150
+ "actor_id": payload["actor_id"],
151
+ "target_id": payload["target_id"],
152
+ "reason": reason,
153
+ "expires_in": _ttl,
154
+ },
155
+ )
156
+ # Re-compose override now to wrap any late overrides set by tests/harness
157
+ try:
158
+ _compose_override()
159
+ except Exception:
160
+ pass
161
+
162
+ @r.post("/impersonate/stop", status_code=204)
163
+ async def stop_impersonation(response: Response):
164
+ response.delete_cookie(_cookie, path="/")
165
+ logger.info("admin.impersonation.stopped")
166
+
167
+ app.include_router(r)
168
+
169
+ # Dependency override: wrap the base principal to honor impersonation cookie.
170
+ # Compose with any existing override (e.g., acceptance app/test harness) and
171
+ # re-compose at startup to capture late overrides.
172
+ def _compose_override():
173
+ existing = app.dependency_overrides.get(_current_principal)
174
+ if existing and getattr(existing, "_is_admin_impersonation_override", False):
175
+ dep_provider = getattr(existing, "_admin_impersonation_base", _current_principal)
176
+ else:
177
+ dep_provider = existing or _current_principal
178
+
179
+ async def _override_current_principal(
180
+ base: Principal = Depends(dep_provider),
181
+ request: Request = None,
182
+ session: SqlSessionDep = None,
183
+ ) -> Principal:
184
+ token = request.cookies.get(_cookie) if request else None
185
+ if not token:
186
+ return base
187
+ try:
188
+ payload = _verify(token, secret=_secret)
189
+ except Exception:
190
+ return base
191
+ # Load target user
192
+ target_id = payload.get("target_id")
193
+ if not target_id:
194
+ return base
195
+ # Preserve actor roles/claims so permissions remain that of the actor
196
+ actor_user = getattr(base, "user", None)
197
+ actor_roles = getattr(actor_user, "roles", []) or []
198
+ _res = (
199
+ user_getter(request, target_id)
200
+ if user_getter
201
+ else _default_user_getter(request, target_id, session)
202
+ )
203
+ target = await _res if inspect.isawaitable(_res) else _res
204
+ # Swap user but keep actor for audit if needed
205
+ setattr(base, "actor", getattr(base, "user", None))
206
+ # If target lacks roles, inherit actor roles to maintain permission checks
207
+ try:
208
+ if not getattr(target, "roles", None):
209
+ setattr(target, "roles", actor_roles)
210
+ except Exception:
211
+ # Best-effort; if target object is immutable, fallback by wrapping
212
+ target = SimpleNamespace(id=getattr(target, "id", target_id), roles=actor_roles)
213
+ base.user = target
214
+ base.via = "impersonated"
215
+ return base
216
+
217
+ app.dependency_overrides[_current_principal] = _override_current_principal
218
+ _override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
219
+ _override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
220
+
221
+ # Compose now (best-effort) and again on startup to wrap any later overrides
222
+ _compose_override()
223
+ try:
224
+ app.add_event_handler("startup", _compose_override)
225
+ except Exception:
226
+ # Best-effort; if app doesn't support event handlers, we already composed once
227
+ pass
228
+ app.state._admin_added = True
229
+
230
+
231
+ # no extra helpers