svc-infra 0.1.506__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 (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import uuid
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Optional
8
+
9
+ from sqlalchemy import (
10
+ JSON,
11
+ Boolean,
12
+ DateTime,
13
+ ForeignKey,
14
+ Index,
15
+ String,
16
+ Text,
17
+ UniqueConstraint,
18
+ text,
19
+ )
20
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
21
+
22
+ from svc_infra.db.sql.base import ModelBase
23
+ from svc_infra.db.sql.types import GUID
24
+
25
+ # ----------------------------- Models -----------------------------------------
26
+
27
+
28
+ class AuthSession(ModelBase):
29
+ __tablename__ = "auth_sessions"
30
+
31
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
32
+ user_id: Mapped[uuid.UUID] = mapped_column(
33
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
34
+ )
35
+ tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
36
+ user_agent: Mapped[Optional[str]] = mapped_column(String(512))
37
+ ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
38
+ last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
39
+ revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
40
+ revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
41
+
42
+ refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
43
+ back_populates="session", cascade="all, delete-orphan", lazy="selectin"
44
+ )
45
+
46
+ created_at = mapped_column(
47
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
48
+ )
49
+
50
+
51
+ class RefreshToken(ModelBase):
52
+ __tablename__ = "refresh_tokens"
53
+
54
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
55
+ session_id: Mapped[uuid.UUID] = mapped_column(
56
+ GUID(), ForeignKey("auth_sessions.id", ondelete="CASCADE"), index=True
57
+ )
58
+ session: Mapped[AuthSession] = relationship(back_populates="refresh_tokens")
59
+
60
+ token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
61
+ rotated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
62
+ revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
63
+ revoke_reason: Mapped[Optional[str]] = mapped_column(Text)
64
+ expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
65
+
66
+ created_at = mapped_column(
67
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
68
+ )
69
+
70
+ __table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
71
+
72
+
73
+ class RefreshTokenRevocation(ModelBase):
74
+ __tablename__ = "refresh_token_revocations"
75
+
76
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
77
+ token_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
78
+ revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
79
+ reason: Mapped[Optional[str]] = mapped_column(Text)
80
+
81
+
82
+ class FailedAuthAttempt(ModelBase):
83
+ __tablename__ = "failed_auth_attempts"
84
+
85
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
86
+ user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
87
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=True
88
+ )
89
+ ip_hash: Mapped[Optional[str]] = mapped_column(String(64), index=True)
90
+ ts: Mapped[datetime] = mapped_column(
91
+ DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
92
+ )
93
+ success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
94
+
95
+ __table_args__ = (Index("ix_failed_attempt_user_time", "user_id", "ts"),)
96
+
97
+
98
+ class RolePermission(ModelBase):
99
+ __tablename__ = "role_permissions"
100
+
101
+ role: Mapped[str] = mapped_column(String(64), primary_key=True)
102
+ permission: Mapped[str] = mapped_column(String(128), primary_key=True)
103
+
104
+
105
+ class AuditLog(ModelBase):
106
+ __tablename__ = "audit_logs"
107
+
108
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
109
+ ts: Mapped[datetime] = mapped_column(
110
+ DateTime(timezone=True),
111
+ nullable=False,
112
+ default=lambda: datetime.now(timezone.utc),
113
+ index=True,
114
+ )
115
+ actor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
116
+ GUID(), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
117
+ )
118
+ tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
119
+ event_type: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
120
+ resource_ref: Mapped[Optional[str]] = mapped_column(String(255), index=True)
121
+ event_metadata: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
122
+ prev_hash: Mapped[Optional[str]] = mapped_column(String(64))
123
+ hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
124
+
125
+ __table_args__ = (Index("ix_audit_chain", "tenant_id", "id"),)
126
+
127
+
128
+ # ------------------------ Org / Teams ----------------------------------------
129
+
130
+
131
+ class Organization(ModelBase):
132
+ __tablename__ = "organizations"
133
+
134
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
135
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
136
+ slug: Mapped[Optional[str]] = mapped_column(String(64), index=True)
137
+ tenant_id: Mapped[Optional[str]] = mapped_column(String(64), index=True)
138
+ created_at = mapped_column(
139
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
140
+ )
141
+
142
+
143
+ class Team(ModelBase):
144
+ __tablename__ = "teams"
145
+
146
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
147
+ org_id: Mapped[uuid.UUID] = mapped_column(
148
+ GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
149
+ )
150
+ name: Mapped[str] = mapped_column(String(128), nullable=False)
151
+ created_at = mapped_column(
152
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
153
+ )
154
+
155
+
156
+ class OrganizationMembership(ModelBase):
157
+ __tablename__ = "organization_memberships"
158
+
159
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
160
+ org_id: Mapped[uuid.UUID] = mapped_column(
161
+ GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
162
+ )
163
+ user_id: Mapped[uuid.UUID] = mapped_column(
164
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), index=True
165
+ )
166
+ role: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
167
+ created_at = mapped_column(
168
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
169
+ )
170
+ deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
171
+
172
+ __table_args__ = (UniqueConstraint("org_id", "user_id", name="uq_org_user_membership"),)
173
+
174
+
175
+ class OrganizationInvitation(ModelBase):
176
+ __tablename__ = "organization_invitations"
177
+
178
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
179
+ org_id: Mapped[uuid.UUID] = mapped_column(
180
+ GUID(), ForeignKey("organizations.id", ondelete="CASCADE"), index=True
181
+ )
182
+ email: Mapped[str] = mapped_column(String(255), index=True)
183
+ role: Mapped[str] = mapped_column(String(64), nullable=False)
184
+ token_hash: Mapped[str] = mapped_column(String(64), index=True)
185
+ expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), index=True)
186
+ created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
187
+ GUID(), ForeignKey("users.id", ondelete="SET NULL"), index=True
188
+ )
189
+ created_at = mapped_column(
190
+ DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
191
+ )
192
+ last_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
193
+ resend_count: Mapped[int] = mapped_column(default=0)
194
+ used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
195
+ revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
196
+
197
+
198
+ # ------------------------ Utilities -------------------------------------------
199
+
200
+
201
+ def generate_refresh_token() -> str:
202
+ """Generate a random refresh token (opaque)."""
203
+ return uuid.uuid4().hex + uuid.uuid4().hex # 64 hex chars
204
+
205
+
206
+ def hash_refresh_token(raw: str) -> str:
207
+ return hashlib.sha256(raw.encode()).hexdigest()
208
+
209
+
210
+ def compute_audit_hash(
211
+ prev_hash: Optional[str],
212
+ *,
213
+ ts: datetime,
214
+ actor_id: Optional[uuid.UUID],
215
+ tenant_id: Optional[str],
216
+ event_type: str,
217
+ resource_ref: Optional[str],
218
+ metadata: dict,
219
+ ) -> str:
220
+ """Compute SHA256 hash chaining previous hash + canonical event payload."""
221
+ prev = prev_hash or "0" * 64
222
+ payload = {
223
+ "ts": ts.isoformat(),
224
+ "actor_id": str(actor_id) if actor_id else None,
225
+ "tenant_id": tenant_id,
226
+ "event_type": event_type,
227
+ "resource_ref": resource_ref,
228
+ "metadata": metadata,
229
+ }
230
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
231
+ return hashlib.sha256((prev + canonical).encode()).hexdigest()
232
+
233
+
234
+ def rotate_refresh_token(
235
+ current_hash: str, *, ttl_minutes: int = 10080
236
+ ) -> tuple[str, str, datetime]:
237
+ """Rotate: returns (new_raw, new_hash, expires_at)."""
238
+ new_raw = generate_refresh_token()
239
+ new_hash = hash_refresh_token(new_raw)
240
+ expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
241
+ return new_raw, new_hash, expires_at
242
+
243
+
244
+ __all__ = [
245
+ "AuthSession",
246
+ "RefreshToken",
247
+ "RefreshTokenRevocation",
248
+ "FailedAuthAttempt",
249
+ "RolePermission",
250
+ "AuditLog",
251
+ "generate_refresh_token",
252
+ "hash_refresh_token",
253
+ "compute_audit_hash",
254
+ "rotate_refresh_token",
255
+ ]
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import uuid
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, Optional
7
+
8
+ try:
9
+ from sqlalchemy import select
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+ except Exception: # pragma: no cover
12
+ AsyncSession = object # type: ignore
13
+ select = None # type: ignore
14
+
15
+ from .models import OrganizationInvitation, OrganizationMembership
16
+
17
+
18
+ def _hash_token(raw: str) -> str:
19
+ return hashlib.sha256(raw.encode()).hexdigest()
20
+
21
+
22
+ def _new_token() -> str:
23
+ return uuid.uuid4().hex + uuid.uuid4().hex
24
+
25
+
26
+ async def issue_invitation(
27
+ db: Any,
28
+ *,
29
+ org_id: uuid.UUID,
30
+ email: str,
31
+ role: str,
32
+ created_by: Optional[uuid.UUID] = None,
33
+ ttl_hours: int = 72,
34
+ ) -> tuple[str, OrganizationInvitation]:
35
+ """Create a new invitation; revoke any existing active invites for the same email+org."""
36
+ # Revoke existing active invites
37
+ if select is not None and hasattr(db, "execute"):
38
+ try:
39
+ rows = (
40
+ (
41
+ await db.execute(
42
+ select(OrganizationInvitation).where(
43
+ OrganizationInvitation.org_id == org_id,
44
+ OrganizationInvitation.email == email,
45
+ OrganizationInvitation.used_at.is_(None),
46
+ OrganizationInvitation.revoked_at.is_(None),
47
+ )
48
+ )
49
+ )
50
+ .scalars()
51
+ .all()
52
+ )
53
+ now = datetime.now(timezone.utc)
54
+ for r in rows:
55
+ r.revoked_at = now
56
+ except Exception: # pragma: no cover
57
+ pass
58
+ else:
59
+ # FakeDB path: revoke in-memory invites
60
+ if hasattr(db, "added"):
61
+ now = datetime.now(timezone.utc)
62
+ for r in list(getattr(db, "added")):
63
+ if (
64
+ isinstance(r, OrganizationInvitation)
65
+ and r.org_id == org_id
66
+ and r.email == email.lower().strip()
67
+ and r.used_at is None
68
+ and r.revoked_at is None
69
+ ):
70
+ r.revoked_at = now
71
+
72
+ raw = _new_token()
73
+ inv = OrganizationInvitation(
74
+ org_id=org_id,
75
+ email=email.lower().strip(),
76
+ role=role,
77
+ token_hash=_hash_token(raw),
78
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=ttl_hours),
79
+ created_by=created_by,
80
+ last_sent_at=datetime.now(timezone.utc),
81
+ resend_count=0,
82
+ )
83
+ if hasattr(db, "add"):
84
+ db.add(inv)
85
+ if hasattr(db, "flush"):
86
+ await db.flush()
87
+ return raw, inv
88
+
89
+
90
+ async def resend_invitation(db: Any, *, invitation: OrganizationInvitation) -> str:
91
+ raw = _new_token()
92
+ invitation.token_hash = _hash_token(raw)
93
+ invitation.last_sent_at = datetime.now(timezone.utc)
94
+ invitation.resend_count = (invitation.resend_count or 0) + 1
95
+ if hasattr(db, "flush"):
96
+ await db.flush()
97
+ return raw
98
+
99
+
100
+ async def accept_invitation(
101
+ db: Any,
102
+ *,
103
+ invitation: OrganizationInvitation,
104
+ user_id: uuid.UUID,
105
+ ) -> OrganizationMembership:
106
+ now = datetime.now(timezone.utc)
107
+ if invitation.revoked_at or invitation.used_at:
108
+ raise ValueError("invitation_unusable")
109
+ if invitation.expires_at and invitation.expires_at < now:
110
+ raise ValueError("invitation_expired")
111
+
112
+ # mark used
113
+ invitation.used_at = now
114
+
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)
117
+ if hasattr(db, "add"):
118
+ db.add(mem)
119
+ if hasattr(db, "flush"):
120
+ await db.flush()
121
+ return mem
122
+
123
+
124
+ __all__ = [
125
+ "issue_invitation",
126
+ "resend_invitation",
127
+ "accept_invitation",
128
+ ]
@@ -0,0 +1,77 @@
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(term in lowered for term in COMMON_PASSWORDS):
64
+ reasons.append("common_password")
65
+ if policy.forbid_breached and not HIBP_DISABLED:
66
+ if _breached_checker and _breached_checker(pw):
67
+ reasons.append("breached_password")
68
+ if reasons:
69
+ raise PasswordValidationError(reasons)
70
+
71
+
72
+ __all__ = [
73
+ "PasswordPolicy",
74
+ "validate_password",
75
+ "PasswordValidationError",
76
+ "configure_breached_checker",
77
+ ]
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Any, Awaitable, Callable, Dict, Iterable, Set
5
+
6
+ from fastapi import Depends, HTTPException
7
+
8
+ from svc_infra.api.fastapi.auth.security import Identity
9
+
10
+ # Central role -> permissions mapping. Projects can extend at startup.
11
+ PERMISSION_REGISTRY: Dict[str, Set[str]] = {
12
+ "admin": {
13
+ "user.read",
14
+ "user.write",
15
+ "billing.read",
16
+ "billing.write",
17
+ "security.session.revoke",
18
+ "security.session.list",
19
+ "admin.impersonate",
20
+ },
21
+ "support": {"user.read", "billing.read"},
22
+ "auditor": {"user.read", "billing.read", "audit.read"},
23
+ }
24
+
25
+
26
+ def get_permissions_for_roles(roles: Iterable[str]) -> Set[str]:
27
+ perms: Set[str] = set()
28
+ for r in roles:
29
+ perms |= PERMISSION_REGISTRY.get(r, set())
30
+ return perms
31
+
32
+
33
+ def principal_permissions(principal: Identity) -> Set[str]:
34
+ roles = getattr(principal.user, "roles", []) or []
35
+ return get_permissions_for_roles(roles)
36
+
37
+
38
+ def has_permission(principal: Identity, permission: str) -> bool:
39
+ return permission in principal_permissions(principal)
40
+
41
+
42
+ def RequirePermission(*needed: str):
43
+ """FastAPI dependency enforcing all listed permissions are present."""
44
+
45
+ async def _guard(principal: Identity):
46
+ perms = principal_permissions(principal)
47
+ missing = [p for p in needed if p not in perms]
48
+ if missing:
49
+ raise HTTPException(403, f"missing_permissions:{','.join(missing)}")
50
+ return principal
51
+
52
+ return Depends(_guard)
53
+
54
+
55
+ def RequireAnyPermission(*candidates: str):
56
+ async def _guard(principal: Identity):
57
+ perms = principal_permissions(principal)
58
+ if not (perms & set(candidates)):
59
+ raise HTTPException(403, "insufficient_permissions")
60
+ return principal
61
+
62
+ return Depends(_guard)
63
+
64
+
65
+ # ------- ABAC (Attribute-Based Access Control) helpers -------
66
+ ABACPredicate = Callable[[Identity, Any], bool | Awaitable[bool]]
67
+
68
+
69
+ def owns_resource(attr: str = "owner_id") -> ABACPredicate:
70
+ def _predicate(principal: Identity, resource: Any) -> bool:
71
+ user = getattr(principal, "user", None)
72
+ uid = getattr(user, "id", None)
73
+ rid = getattr(resource, attr, None) or getattr(resource, "user_id", None)
74
+ return bool(uid is not None and rid is not None and str(uid) == str(rid))
75
+
76
+ return _predicate
77
+
78
+
79
+ async def _maybe_await(v):
80
+ if inspect.isawaitable(v):
81
+ return await v
82
+ return v
83
+
84
+
85
+ def enforce_abac(
86
+ principal: Identity,
87
+ *,
88
+ permission: str,
89
+ resource: Any,
90
+ predicate: ABACPredicate,
91
+ ):
92
+ perms = principal_permissions(principal)
93
+ if permission not in perms:
94
+ raise HTTPException(403, f"missing_permissions:{permission}")
95
+ ok = False
96
+ # allow sync or async predicate
97
+ res = predicate(principal, resource)
98
+ if inspect.isawaitable(res):
99
+ # Fast path for sync contexts: raise clear guidance
100
+ raise RuntimeError(
101
+ "enforce_abac received an async predicate in a sync context; use RequireABAC for FastAPI dependencies."
102
+ )
103
+ else:
104
+ ok = bool(res)
105
+ if not ok:
106
+ raise HTTPException(403, "forbidden")
107
+ return principal
108
+
109
+
110
+ def RequireABAC(
111
+ *,
112
+ permission: str,
113
+ predicate: ABACPredicate,
114
+ resource_getter: Callable[..., Any],
115
+ ):
116
+ """FastAPI dependency: enforce permission and attribute check using a resource provider.
117
+
118
+ Example:
119
+ def load_doc(): ...
120
+ @router.get("/docs/{doc_id}", dependencies=[RequireABAC(permission="doc.read", predicate=owns_resource(), resource_getter=load_doc)])
121
+ async def get_doc(identity: Identity, doc = Depends(load_doc)):
122
+ ...
123
+ Note: Using the provider in both the dependency and endpoint will call it twice. For heavy
124
+ providers, wire only in the dependency and re-fetch via the dependency override or request.state.
125
+ """
126
+
127
+ async def _guard(principal: Identity, resource: Any = Depends(resource_getter)):
128
+ perms = principal_permissions(principal)
129
+ if permission not in perms:
130
+ raise HTTPException(403, f"missing_permissions:{permission}")
131
+ ok = await _maybe_await(predicate(principal, resource))
132
+ if not ok:
133
+ raise HTTPException(403, "forbidden")
134
+ return principal
135
+
136
+ return Depends(_guard)
137
+
138
+
139
+ __all__ = [
140
+ "PERMISSION_REGISTRY",
141
+ "get_permissions_for_roles",
142
+ "principal_permissions",
143
+ "has_permission",
144
+ "RequirePermission",
145
+ "RequireAnyPermission",
146
+ "RequireABAC",
147
+ "enforce_abac",
148
+ "owns_resource",
149
+ ]