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.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
svc_infra/security/models.py
CHANGED
|
@@ -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
|
|
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),
|
|
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(
|
|
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),
|
|
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(
|
|
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),
|
|
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),
|
|
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),
|
|
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),
|
|
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__ = (
|
|
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(
|
|
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),
|
|
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(
|
|
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"):
|
svc_infra/security/passwords.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
28
|
-
|
|
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",
|
svc_infra/security/session.py
CHANGED
|
@@ -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
|
+
]
|